From 1297c135156fba6bd4ec3d381a54503dbf30b3ec Mon Sep 17 00:00:00 2001 From: Connie Ye Date: Thu, 19 Feb 2026 20:32:46 -0800 Subject: [PATCH 1/3] fork color picker --- .../IDE/components/Editor/codemirror.js | 1 - .../IDE/components/Editor/colorpicker/LICENSE | 22 ++ .../components/Editor/colorpicker/index.ts | 359 ++++++++++++++++++ .../components/Editor/colorpicker/utils.ts | 85 +++++ .../IDE/components/Editor/stateUtils.js | 3 +- 5 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 client/modules/IDE/components/Editor/colorpicker/LICENSE create mode 100644 client/modules/IDE/components/Editor/colorpicker/index.ts create mode 100644 client/modules/IDE/components/Editor/colorpicker/utils.ts diff --git a/client/modules/IDE/components/Editor/codemirror.js b/client/modules/IDE/components/Editor/codemirror.js index a711254763..9b561a1401 100644 --- a/client/modules/IDE/components/Editor/codemirror.js +++ b/client/modules/IDE/components/Editor/codemirror.js @@ -23,7 +23,6 @@ import tidyCodeWithPrettier from './tidier'; // ----- GENERAL TODOS (in order of priority) ----- // - color themes // - any features lost in the p5 conversion git merge -// - javascript color picker (extension works for css but needs to be forked for js) // - revisit keymap differences, esp around sublime // - emmet doesn't trigger if text is copy pasted in // - need to re-implement emmet auto rename tag diff --git a/client/modules/IDE/components/Editor/colorpicker/LICENSE b/client/modules/IDE/components/Editor/colorpicker/LICENSE new file mode 100644 index 0000000000..e00a6bab4c --- /dev/null +++ b/client/modules/IDE/components/Editor/colorpicker/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2021 uiw +Modified by Connie Ye in 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/client/modules/IDE/components/Editor/colorpicker/index.ts b/client/modules/IDE/components/Editor/colorpicker/index.ts new file mode 100644 index 0000000000..01b31b2f51 --- /dev/null +++ b/client/modules/IDE/components/Editor/colorpicker/index.ts @@ -0,0 +1,359 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable lines-between-class-members */ +/* eslint-disable dot-notation */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable prefer-destructuring */ +/* eslint-disable prefer-const */ +/* eslint-disable prefer-template */ +/* eslint-disable @typescript-eslint/no-shadow */ + +import { + ViewPlugin, + EditorView, + ViewUpdate, + WidgetType, + Decoration, + DecorationSet +} from '@codemirror/view'; +import { Extension, Range } from '@codemirror/state'; +import { syntaxTree } from '@codemirror/language'; +import colors from 'colors-named'; +import hexs from 'colors-named-hex'; +import hslMatcher, { hlsStringToRGB, RGBAColor } from 'hsl-matcher'; +import { toFullHex, rgbToHex, hexToRgb, RGBToHSL } from './utils'; + +export enum ColorType { + rgb = 'RGB', + hex = 'HEX', + named = 'NAMED', + hsl = 'HSL' +} + +export interface ColorState { + from: number; + to: number; + alpha: string; + colorType: ColorType; +} + +const colorState = new WeakMap(); + +type GetArrayElementType< + T extends readonly any[] +> = T extends readonly (infer U)[] ? U : never; + +const matchingTypes = ['CallExpression', 'String']; + +function colorDecorations(view: EditorView) { + const widgets: Array> = []; + for (const range of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from: range.from, + to: range.to, + enter: ({ type, from, to }) => { + const rawCallExp: string = view.state.doc.sliceString(from, to); + const callExp = + type.name === 'String' ? rawCallExp.replaceAll('"', '') : rawCallExp; + /** + * ``` + * rgb(0 107 128, .5); ❌ ❌ ❌ + * rgb( 0 107 128 ); ✅ ✅ ✅ + * RGB( 0 107 128 ); ✅ ✅ ✅ + * Rgb( 0 107 128 ); ✅ ✅ ✅ + * rgb( 0 107 128 / ); ❌ ❌ ❌ + * rgb( 0 107 128 / 60%); ✅ ✅ ✅ + * rgb(0,107,128 / 60%); ❌ ❌ ❌ + * rgb( 255, 255, 255 ) ✅ ✅ ✅ + * rgba( 255, 255, 255 ) ✅ ✅ ✅ + * rgba( 255, 255 , 255, ) ❌ ❌ ❌ + * rgba( 255, 255 , 255, .5 ) ✅ ✅ ✅ + * rgba( 255 255 255 / 0.5 ); ✅ ✅ ✅ + * rgba( 255 255 255 0.5 ); ❌ ❌ ❌ + * rgba( 255 255 255 / ); ❌ ❌ ❌ + * ``` + */ + // console.log( + // 'checking', + // { type: type.name, callExp }, + // matchingTypes.includes(type.name) + // ); + // console.log('checking', callExp.startsWith('rgb')); + + if (matchingTypes.includes(type.name) && callExp.startsWith('rgb')) { + // console.log('rgb matched', callExp); + const match = + /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,?\s*(\d{1,3})\s*(,\s*\d*\.\d*\s*)?\)/i.exec( + callExp + ) || + /rgba?\(\s*(\d{1,3})\s*(\d{1,3})\s*(\d{1,3})\s*(\/?\s*\d+%)?(\/\s*\d+\.\d\s*)?\)/i.exec( + callExp + ); + if (!match) return; + const [_, r, g, b, a] = match; + const hex = rgbToHex(Number(r), Number(g), Number(b)); + const widget = Decoration.widget({ + widget: new ColorWidget({ + colorType: ColorType.rgb, + color: hex, + colorRaw: callExp, + from, + to, + alpha: a ? a.replace(/(\/|,)/g, '') : '' + }), + side: 0 + }); + // console.log('pushing widget for rgb', { r, g, b, a, hex }); + widgets.push(widget.range(from)); + } else if (matchingTypes.includes(type.name) && hslMatcher(callExp)) { + /** + * # valid + * hsl(240, 100%, 50%) // ✅ comma separated + * hsl(240, 100%, 50%, 0.1) // ✅ comma separated with opacity + * hsl(240, 100%, 50%, 10%) // ✅ comma separated with % opacity + * hsl(240,100%,50%,0.1) // ✅ comma separated without spaces + * hsl(180deg, 100%, 50%, 0.1) // ✅ hue with 'deg' + * hsl(3.14rad, 100%, 50%, 0.1) // ✅ hue with 'rad' + * hsl(200grad, 100%, 50%, 0.1) // ✅ hue with 'grad' + * hsl(0.5turn, 100%, 50%, 0.1) // ✅ hue with 'turn' + * hsl(-240, -100%, -50%, -0.1) // ✅ negative values + * hsl(+240, +100%, +50%, +0.1) // ✅ explicit positive sign + * hsl(240.5, 99.99%, 49.999%, 0.9999) // ✅ non-integer values + * hsl(.9, .99%, .999%, .9999) // ✅ fraction w/o leading zero + * hsl(0240, 0100%, 0050%, 01) // ✅ leading zeros + * hsl(240.0, 100.00%, 50.000%, 1.0000) // ✅ trailing decimal zeros + * hsl(2400, 1000%, 1000%, 10) // ✅ out of range values + * hsl(-2400.01deg, -1000.5%, -1000.05%, -100) // ✅ combination of above + * hsl(2.40e+2, 1.00e+2%, 5.00e+1%, 1E-3) // ✅ scientific notation + * hsl(240 100% 50%) // ✅ space separated (CSS Color Level 4) + * hsl(240 100% 50% / 0.1) // ✅ space separated with opacity + * hsla(240, 100%, 50%) // ✅ hsla() alias + * hsla(240, 100%, 50%, 0.1) // ✅ hsla() with opacity + * HSL(240Deg, 100%, 50%) // ✅ case insensitive + */ + const match = hlsStringToRGB(callExp) as RGBAColor; + if (!match) return; + const { r, g, b } = match; + const hex = rgbToHex(Number(r), Number(g), Number(b)); + const widget = Decoration.widget({ + widget: new ColorWidget({ + colorType: ColorType.hsl, + color: hex, + colorRaw: callExp, + from, + to, + alpha: match.a ? match.a.toString() : '' + }), + side: 0 + }); + widgets.push(widget.range(from)); + } else if (type.name === 'ColorLiteral') { + const [color, alpha] = toFullHex(callExp); + const widget = Decoration.widget({ + widget: new ColorWidget({ + colorType: ColorType.hex, + color, + colorRaw: callExp, + from, + to, + alpha + }), + side: 0 + }); + widgets.push(widget.range(from)); + } else if (type.name === 'ValueName') { + const name = (callExp as unknown) as GetArrayElementType< + typeof colors + >; + if (colors.includes(name)) { + const widget = Decoration.widget({ + widget: new ColorWidget({ + colorType: ColorType.named, + color: hexs[colors.indexOf(name)], + colorRaw: callExp, + from, + to, + alpha: '' + }), + side: 0 + }); + widgets.push(widget.range(from)); + } + } + } + }); + } + return Decoration.set(widgets); +} + +function pickColor(e: Event, view: EditorView) { + const target = e.target as HTMLInputElement; + console.log( + 'pick color event', + e, + target, + target.dataset.color, + target.dataset.colorraw + ); + if ( + target.nodeName !== 'INPUT' || + !target.parentElement || + (!target.dataset.color && !target.dataset.colorraw) + ) + return false; + const data = colorState.get(target)!; + const value = target.value; + const rgb = hexToRgb(value); + const colorraw = target.dataset.colorraw; + const slash = (target.dataset.colorraw || '').indexOf('/') > 4; + const comma = (target.dataset.colorraw || '').indexOf(',') > 4; + let converted = target.value; + if (data.colorType === ColorType.rgb) { + // console.log({ colorraw, slash, comma }); + let funName = colorraw?.match(/^(rgba?)/) + ? colorraw?.match(/^(rgba?)/)![0] + : undefined; + if (comma) { + converted = rgb + ? `${funName}(${rgb.r}, ${rgb.g}, ${rgb.b}${ + data.alpha ? ', ' + data.alpha.trim() : '' + })` + : value; + } else if (slash) { + converted = rgb + ? `${funName}(${rgb.r} ${rgb.g} ${rgb.b}${ + data.alpha ? ' / ' + data.alpha.trim() : '' + })` + : value; + } else { + converted = rgb ? `${funName}(${rgb.r} ${rgb.g} ${rgb.b})` : value; + } + } else if (data.colorType === ColorType.hsl) { + const rgb = hexToRgb(value); + if (rgb) { + const { h, s, l } = RGBToHSL(rgb?.r, rgb?.g, rgb?.b); + converted = `hsl(${h}deg ${s}% ${l}%${ + data.alpha ? ' / ' + data.alpha : '' + })`; + } + } + view.dispatch({ + changes: { + from: data.from, + to: data.to, + insert: converted + } + }); + return true; +} + +class ColorWidget extends WidgetType { + private readonly state: ColorState; + private readonly color: string; + private readonly colorRaw: string; + + constructor({ + color, + colorRaw, + ...state + }: ColorState & { + color: string; + colorRaw: string; + }) { + super(); + this.state = state; + this.color = color; + this.colorRaw = colorRaw; + } + eq(other: ColorWidget) { + return ( + other.state.colorType === this.state.colorType && + other.color === this.color && + other.state.from === this.state.from && + other.state.to === this.state.to && + other.state.alpha === this.state.alpha + ); + } + toDOM() { + const picker = document.createElement('input'); + colorState.set(picker, this.state); + picker.type = 'color'; + picker.value = this.color; + picker.dataset['color'] = this.color; + picker.dataset['colorraw'] = this.colorRaw; + const wrapper = document.createElement('span'); + wrapper.appendChild(picker); + wrapper.dataset['color'] = this.color; + wrapper.style.backgroundColor = this.colorRaw; + return wrapper; + } + ignoreEvent() { + return false; + } +} + +export const colorView = (showPicker: boolean = true) => + ViewPlugin.fromClass( + class ColorView { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = colorDecorations(view); + } + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = colorDecorations(update.view); + } + const readOnly = update.view.contentDOM.ariaReadOnly === 'true'; + const editable = update.view.contentDOM.contentEditable === 'true'; + + const canBeEdited = readOnly === false && editable; + this.changePicker(update.view, canBeEdited); + } + changePicker(view: EditorView, canBeEdited: boolean) { + const doms = view.contentDOM.querySelectorAll('input[type=color]'); + doms.forEach((inp) => { + if (!showPicker) { + inp.setAttribute('disabled', ''); + } else { + canBeEdited + ? inp.removeAttribute('disabled') + : inp.setAttribute('disabled', ''); + } + }); + } + }, + { + decorations: (v) => v.decorations, + eventHandlers: { + change: pickColor, + blur: pickColor + } + } + ); + +export const colorTheme = EditorView.baseTheme({ + 'span[data-color]': { + width: '12px', + height: '12px', + display: 'inline-block', + borderRadius: '2px', + marginRight: '0.5ch', + outline: '1px solid #00000040', + overflow: 'hidden', + verticalAlign: 'middle', + marginTop: '-2px' + }, + 'span[data-color] input[type="color"]': { + background: 'transparent', + display: 'block', + border: 'none', + outline: '0', + paddingLeft: '24px', + height: '12px' + }, + 'span[data-color] input[type="color"]::-webkit-color-swatch': { + border: 'none', + paddingLeft: '24px' + } +}); + +export const color: Extension = [colorView(), colorTheme]; diff --git a/client/modules/IDE/components/Editor/colorpicker/utils.ts b/client/modules/IDE/components/Editor/colorpicker/utils.ts new file mode 100644 index 0000000000..1019243ab4 --- /dev/null +++ b/client/modules/IDE/components/Editor/colorpicker/utils.ts @@ -0,0 +1,85 @@ +/* eslint-disable no-bitwise */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-sequences */ +/* eslint-disable eqeqeq */ +/* eslint-disable prefer-const */ +/* eslint-disable prefer-template */ +/* eslint-disable no-multi-assign */ +/* eslint-disable one-var */ +/* eslint-disable default-case */ + +export function toFullHex(color: string): string[] { + if (color.length === 4) { + // 3-char hex + return [ + `#${color[1].repeat(2)}${color[2].repeat(2)}${color[3].repeat(2)}`, + '' + ]; + } + + if (color.length === 5) { + // 4-char hex (alpha) + return [ + `#${color[1].repeat(2)}${color[2].repeat(2)}${color[3].repeat(2)}`, + color[4].repeat(2) + ]; + } + + if (color.length === 9) { + // 8-char hex (alpha) + return [`#${color.slice(1, -2)}`, color.slice(-2)]; + } + + return [color, '']; +} +/** https://stackoverflow.com/a/5624139/1334703 */ +export function rgbToHex(r: number, g: number, b: number) { + return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); +} + +/** https://stackoverflow.com/a/5624139/1334703 */ +export function hexToRgb(hex: string) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } + : null; +} + +/** https://css-tricks.com/converting-color-spaces-in-javascript/#aa-rgb-to-hsl */ +export function RGBToHSL(r: number, g: number, b: number) { + (r /= 255), (g /= 255), (b /= 255); + const max = Math.max(r, g, b), + min = Math.min(r, g, b); + let h = 0, + s, + l = (max + min) / 2; + + if (max == min) { + h = s = 0; // achromatic + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + } + h /= 6; + } + return { + h: Math.floor(h * 360), + s: Math.floor(s * 100), + l: Math.floor(l * 100) + }; +} diff --git a/client/modules/IDE/components/Editor/stateUtils.js b/client/modules/IDE/components/Editor/stateUtils.js index 32ac339abd..e980cb358b 100644 --- a/client/modules/IDE/components/Editor/stateUtils.js +++ b/client/modules/IDE/components/Editor/stateUtils.js @@ -37,7 +37,6 @@ import { indentLess } from '@codemirror/commands'; import { lintGutter } from '@codemirror/lint'; -import { color as colorPicker } from '@uiw/codemirror-extensions-color'; import { expandAbbreviation, abbreviationTracker @@ -53,6 +52,7 @@ import { HTMLHint } from 'htmlhint'; import { CSSLint } from 'csslint'; import { emmetConfig } from '@emmetio/codemirror6-plugin'; +import { color as colorPicker } from './colorpicker'; import p5JavaScript from './p5JavaScript'; import tidyCodeWithPrettier from './tidier'; import { highlightStyle } from './highlightStyle'; @@ -99,6 +99,7 @@ function getFileLanguage(fileName) { case 'xml': return xml; case 'application/json': + console.log('returning json language support'); return json; default: return null; From a1df9e96d0180bc0d401b293a34e230391278848 Mon Sep 17 00:00:00 2001 From: Connie Ye Date: Sun, 1 Mar 2026 14:12:49 -0800 Subject: [PATCH 2/3] working through widget bug fixes (incomplete) --- .../components/Editor/colorpicker/index.ts | 162 +++++++++--------- 1 file changed, 83 insertions(+), 79 deletions(-) diff --git a/client/modules/IDE/components/Editor/colorpicker/index.ts b/client/modules/IDE/components/Editor/colorpicker/index.ts index 01b31b2f51..1140e05abd 100644 --- a/client/modules/IDE/components/Editor/colorpicker/index.ts +++ b/client/modules/IDE/components/Editor/colorpicker/index.ts @@ -102,7 +102,7 @@ function colorDecorations(view: EditorView) { }), side: 0 }); - // console.log('pushing widget for rgb', { r, g, b, a, hex }); + console.log('pushing widget for rgb', { r, g, b, a, hex }); widgets.push(widget.range(from)); } else if (matchingTypes.includes(type.name) && hslMatcher(callExp)) { /** @@ -185,65 +185,71 @@ function colorDecorations(view: EditorView) { return Decoration.set(widgets); } -function pickColor(e: Event, view: EditorView) { - const target = e.target as HTMLInputElement; - console.log( - 'pick color event', - e, - target, - target.dataset.color, - target.dataset.colorraw - ); - if ( - target.nodeName !== 'INPUT' || - !target.parentElement || - (!target.dataset.color && !target.dataset.colorraw) - ) - return false; - const data = colorState.get(target)!; - const value = target.value; - const rgb = hexToRgb(value); - const colorraw = target.dataset.colorraw; - const slash = (target.dataset.colorraw || '').indexOf('/') > 4; - const comma = (target.dataset.colorraw || '').indexOf(',') > 4; - let converted = target.value; - if (data.colorType === ColorType.rgb) { - // console.log({ colorraw, slash, comma }); - let funName = colorraw?.match(/^(rgba?)/) - ? colorraw?.match(/^(rgba?)/)![0] - : undefined; - if (comma) { - converted = rgb - ? `${funName}(${rgb.r}, ${rgb.g}, ${rgb.b}${ - data.alpha ? ', ' + data.alpha.trim() : '' - })` - : value; - } else if (slash) { - converted = rgb - ? `${funName}(${rgb.r} ${rgb.g} ${rgb.b}${ - data.alpha ? ' / ' + data.alpha.trim() : '' - })` - : value; - } else { - converted = rgb ? `${funName}(${rgb.r} ${rgb.g} ${rgb.b})` : value; - } - } else if (data.colorType === ColorType.hsl) { +function pickColor(dispatch: boolean) { + return function f(e: Event, view: EditorView) { + const target = e.target as HTMLInputElement; + console.log( + 'pick color event', + e, + target, + target.dataset.color, + target.dataset.colorraw + ); + if ( + target.nodeName !== 'INPUT' || + !target.parentElement || + (!target.dataset.color && !target.dataset.colorraw) + ) + return false; + const data = colorState.get(target)!; + const value = target.value; const rgb = hexToRgb(value); - if (rgb) { - const { h, s, l } = RGBToHSL(rgb?.r, rgb?.g, rgb?.b); - converted = `hsl(${h}deg ${s}% ${l}%${ - data.alpha ? ' / ' + data.alpha : '' - })`; + const colorraw = target.dataset.colorraw; + const slash = (target.dataset.colorraw || '').indexOf('/') > 4; + const comma = (target.dataset.colorraw || '').indexOf(',') > 4; + let converted = target.value; + if (data.colorType === ColorType.rgb) { + // console.log({ colorraw, slash, comma }); + let funName = colorraw?.match(/^(rgba?)/) + ? colorraw?.match(/^(rgba?)/)![0] + : undefined; + if (comma) { + converted = rgb + ? `${funName}(${rgb.r}, ${rgb.g}, ${rgb.b}${ + data.alpha ? ', ' + data.alpha.trim() : '' + })` + : value; + } else if (slash) { + converted = rgb + ? `${funName}(${rgb.r} ${rgb.g} ${rgb.b}${ + data.alpha ? ' / ' + data.alpha.trim() : '' + })` + : value; + } else { + converted = rgb ? `${funName}(${rgb.r} ${rgb.g} ${rgb.b})` : value; + } + } else if (data.colorType === ColorType.hsl) { + const rgb = hexToRgb(value); + if (rgb) { + const { h, s, l } = RGBToHSL(rgb?.r, rgb?.g, rgb?.b); + converted = `hsl(${h}deg ${s}% ${l}%${ + data.alpha ? ' / ' + data.alpha : '' + })`; + } } - } - view.dispatch({ - changes: { - from: data.from, - to: data.to, - insert: converted + if (dispatch) { + view.dispatch({ + changes: { + from: data.from, + to: data.to, + insert: converted + } + }); + data.to = data.from + converted.length; } - }); - return true; + + return true; + }; } class ColorWidget extends WidgetType { @@ -267,10 +273,7 @@ class ColorWidget extends WidgetType { eq(other: ColorWidget) { return ( other.state.colorType === this.state.colorType && - other.color === this.color && - other.state.from === this.state.from && - other.state.to === this.state.to && - other.state.alpha === this.state.alpha + other.state.from === this.state.from ); } toDOM() { @@ -300,32 +303,33 @@ export const colorView = (showPicker: boolean = true) => } update(update: ViewUpdate) { if (update.docChanged || update.viewportChanged) { + console.log('updating decorations for color widget'); this.decorations = colorDecorations(update.view); } - const readOnly = update.view.contentDOM.ariaReadOnly === 'true'; - const editable = update.view.contentDOM.contentEditable === 'true'; + // const readOnly = update.view.contentDOM.ariaReadOnly === 'true'; + // const editable = update.view.contentDOM.contentEditable === 'true'; - const canBeEdited = readOnly === false && editable; - this.changePicker(update.view, canBeEdited); - } - changePicker(view: EditorView, canBeEdited: boolean) { - const doms = view.contentDOM.querySelectorAll('input[type=color]'); - doms.forEach((inp) => { - if (!showPicker) { - inp.setAttribute('disabled', ''); - } else { - canBeEdited - ? inp.removeAttribute('disabled') - : inp.setAttribute('disabled', ''); - } - }); + // const canBeEdited = readOnly === false && editable; + // this.changePicker(update.view, canBeEdited); } + // changePicker(view: EditorView, canBeEdited: boolean) { + // const doms = view.contentDOM.querySelectorAll('input[type=color]'); + // doms.forEach((inp) => { + // if (!showPicker) { + // inp.setAttribute('disabled', ''); + // } else { + // canBeEdited + // ? inp.removeAttribute('disabled') + // : inp.setAttribute('disabled', ''); + // } + // }); + // } }, { decorations: (v) => v.decorations, eventHandlers: { - change: pickColor, - blur: pickColor + change: pickColor(true), + input: pickColor(true) } } ); From b82ff875bf89b15233960aae4d221ac70d6a7446 Mon Sep 17 00:00:00 2001 From: Connie Ye Date: Sun, 8 Mar 2026 17:31:48 -0700 Subject: [PATCH 3/3] it's working! --- .../components/Editor/colorpicker/index.ts | 112 ++++++++++-------- .../components/Editor/colorpicker/utils.ts | 11 ++ 2 files changed, 75 insertions(+), 48 deletions(-) diff --git a/client/modules/IDE/components/Editor/colorpicker/index.ts b/client/modules/IDE/components/Editor/colorpicker/index.ts index 1140e05abd..eced43297e 100644 --- a/client/modules/IDE/components/Editor/colorpicker/index.ts +++ b/client/modules/IDE/components/Editor/colorpicker/index.ts @@ -19,8 +19,14 @@ import { Extension, Range } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; import colors from 'colors-named'; import hexs from 'colors-named-hex'; -import hslMatcher, { hlsStringToRGB, RGBAColor } from 'hsl-matcher'; -import { toFullHex, rgbToHex, hexToRgb, RGBToHSL } from './utils'; +import { hlsStringToRGB, RGBAColor } from 'hsl-matcher'; +import { + toFullHex, + rgbToHex, + hexToRgb, + RGBToHSL, + hasStringFormatting +} from './utils'; export enum ColorType { rgb = 'RGB', @@ -34,6 +40,7 @@ export interface ColorState { to: number; alpha: string; colorType: ColorType; + stringFormatCharacter?: string; } const colorState = new WeakMap(); @@ -42,7 +49,7 @@ type GetArrayElementType< T extends readonly any[] > = T extends readonly (infer U)[] ? U : never; -const matchingTypes = ['CallExpression', 'String']; +const matchingTypes = ['CallExpression', 'String', 'ColorLiteral']; function colorDecorations(view: EditorView) { const widgets: Array> = []; @@ -52,8 +59,10 @@ function colorDecorations(view: EditorView) { to: range.to, enter: ({ type, from, to }) => { const rawCallExp: string = view.state.doc.sliceString(from, to); - const callExp = - type.name === 'String' ? rawCallExp.replaceAll('"', '') : rawCallExp; + const stringFormatCharacter = hasStringFormatting(rawCallExp); + const callExp = stringFormatCharacter + ? rawCallExp.replaceAll(stringFormatCharacter, '') + : rawCallExp; /** * ``` * rgb(0 107 128, .5); ❌ ❌ ❌ @@ -72,15 +81,11 @@ function colorDecorations(view: EditorView) { * rgba( 255 255 255 / ); ❌ ❌ ❌ * ``` */ - // console.log( - // 'checking', - // { type: type.name, callExp }, - // matchingTypes.includes(type.name) - // ); - // console.log('checking', callExp.startsWith('rgb')); - if (matchingTypes.includes(type.name) && callExp.startsWith('rgb')) { - // console.log('rgb matched', callExp); + if ( + matchingTypes.includes(type.name) && + callExp.toLowerCase().startsWith('rgb') + ) { const match = /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,?\s*(\d{1,3})\s*(,\s*\d*\.\d*\s*)?\)/i.exec( callExp @@ -98,13 +103,16 @@ function colorDecorations(view: EditorView) { colorRaw: callExp, from, to, - alpha: a ? a.replace(/(\/|,)/g, '') : '' + alpha: a ? a.replace(/(\/|,)/g, '') : '', + stringFormatCharacter }), side: 0 }); - console.log('pushing widget for rgb', { r, g, b, a, hex }); widgets.push(widget.range(from)); - } else if (matchingTypes.includes(type.name) && hslMatcher(callExp)) { + } else if ( + matchingTypes.includes(type.name) && + callExp.toLowerCase().startsWith('hsl') + ) { /** * # valid * hsl(240, 100%, 50%) // ✅ comma separated @@ -141,12 +149,16 @@ function colorDecorations(view: EditorView) { colorRaw: callExp, from, to, - alpha: match.a ? match.a.toString() : '' + alpha: match.a ? match.a.toString() : '', + stringFormatCharacter }), side: 0 }); widgets.push(widget.range(from)); - } else if (type.name === 'ColorLiteral') { + } else if ( + matchingTypes.includes(type.name) && + callExp.startsWith('#') + ) { const [color, alpha] = toFullHex(callExp); const widget = Decoration.widget({ widget: new ColorWidget({ @@ -155,7 +167,8 @@ function colorDecorations(view: EditorView) { colorRaw: callExp, from, to, - alpha + alpha, + stringFormatCharacter }), side: 0 }); @@ -172,7 +185,8 @@ function colorDecorations(view: EditorView) { colorRaw: callExp, from, to, - alpha: '' + alpha: '', + stringFormatCharacter }), side: 0 }); @@ -188,13 +202,6 @@ function colorDecorations(view: EditorView) { function pickColor(dispatch: boolean) { return function f(e: Event, view: EditorView) { const target = e.target as HTMLInputElement; - console.log( - 'pick color event', - e, - target, - target.dataset.color, - target.dataset.colorraw - ); if ( target.nodeName !== 'INPUT' || !target.parentElement || @@ -209,7 +216,6 @@ function pickColor(dispatch: boolean) { const comma = (target.dataset.colorraw || '').indexOf(',') > 4; let converted = target.value; if (data.colorType === ColorType.rgb) { - // console.log({ colorraw, slash, comma }); let funName = colorraw?.match(/^(rgba?)/) ? colorraw?.match(/^(rgba?)/)![0] : undefined; @@ -232,11 +238,14 @@ function pickColor(dispatch: boolean) { const rgb = hexToRgb(value); if (rgb) { const { h, s, l } = RGBToHSL(rgb?.r, rgb?.g, rgb?.b); - converted = `hsl(${h}deg ${s}% ${l}%${ - data.alpha ? ' / ' + data.alpha : '' + converted = `hsl(${h}, ${s}%, ${l}%${ + data.alpha ? `, ${data.alpha}` : '' })`; } } + converted = data.stringFormatCharacter + ? `${data.stringFormatCharacter}${converted}${data.stringFormatCharacter}` + : converted; if (dispatch) { view.dispatch({ changes: { @@ -256,6 +265,7 @@ class ColorWidget extends WidgetType { private readonly state: ColorState; private readonly color: string; private readonly colorRaw: string; + private wrapper?: HTMLElement; constructor({ color, @@ -273,9 +283,16 @@ class ColorWidget extends WidgetType { eq(other: ColorWidget) { return ( other.state.colorType === this.state.colorType && - other.state.from === this.state.from + other.color === this.color && + other.state.from === this.state.from && + other.state.to === this.state.to && + other.state.alpha === this.state.alpha ); } + updateDOM(dom: HTMLElement, view: EditorView): boolean { + dom.style.backgroundColor = this.colorRaw; + return true; + } toDOM() { const picker = document.createElement('input'); colorState.set(picker, this.state); @@ -303,27 +320,26 @@ export const colorView = (showPicker: boolean = true) => } update(update: ViewUpdate) { if (update.docChanged || update.viewportChanged) { - console.log('updating decorations for color widget'); this.decorations = colorDecorations(update.view); } - // const readOnly = update.view.contentDOM.ariaReadOnly === 'true'; - // const editable = update.view.contentDOM.contentEditable === 'true'; + const readOnly = update.view.contentDOM.ariaReadOnly === 'true'; + const editable = update.view.contentDOM.contentEditable === 'true'; - // const canBeEdited = readOnly === false && editable; - // this.changePicker(update.view, canBeEdited); + const canBeEdited = readOnly === false && editable; + this.changePicker(update.view, canBeEdited); + } + changePicker(view: EditorView, canBeEdited: boolean) { + const doms = view.contentDOM.querySelectorAll('input[type=color]'); + doms.forEach((inp) => { + if (!showPicker) { + inp.setAttribute('disabled', ''); + } else { + canBeEdited + ? inp.removeAttribute('disabled') + : inp.setAttribute('disabled', ''); + } + }); } - // changePicker(view: EditorView, canBeEdited: boolean) { - // const doms = view.contentDOM.querySelectorAll('input[type=color]'); - // doms.forEach((inp) => { - // if (!showPicker) { - // inp.setAttribute('disabled', ''); - // } else { - // canBeEdited - // ? inp.removeAttribute('disabled') - // : inp.setAttribute('disabled', ''); - // } - // }); - // } }, { decorations: (v) => v.decorations, diff --git a/client/modules/IDE/components/Editor/colorpicker/utils.ts b/client/modules/IDE/components/Editor/colorpicker/utils.ts index 1019243ab4..529d12de86 100644 --- a/client/modules/IDE/components/Editor/colorpicker/utils.ts +++ b/client/modules/IDE/components/Editor/colorpicker/utils.ts @@ -8,6 +8,7 @@ /* eslint-disable no-multi-assign */ /* eslint-disable one-var */ /* eslint-disable default-case */ +/* eslint-disable consistent-return */ export function toFullHex(color: string): string[] { if (color.length === 4) { @@ -83,3 +84,13 @@ export function RGBToHSL(r: number, g: number, b: number) { l: Math.floor(l * 100) }; } + +export function hasStringFormatting(rawColor: string): string | undefined { + if (rawColor.startsWith("'") && rawColor.endsWith("'")) { + return "'"; + } + if (rawColor.startsWith('"') && rawColor.endsWith('"')) { + return '"'; + } + return undefined; +}