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..eced43297e --- /dev/null +++ b/client/modules/IDE/components/Editor/colorpicker/index.ts @@ -0,0 +1,379 @@ +/* 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 { hlsStringToRGB, RGBAColor } from 'hsl-matcher'; +import { + toFullHex, + rgbToHex, + hexToRgb, + RGBToHSL, + hasStringFormatting +} from './utils'; + +export enum ColorType { + rgb = 'RGB', + hex = 'HEX', + named = 'NAMED', + hsl = 'HSL' +} + +export interface ColorState { + from: number; + to: number; + alpha: string; + colorType: ColorType; + stringFormatCharacter?: string; +} + +const colorState = new WeakMap(); + +type GetArrayElementType< + T extends readonly any[] +> = T extends readonly (infer U)[] ? U : never; + +const matchingTypes = ['CallExpression', 'String', 'ColorLiteral']; + +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 stringFormatCharacter = hasStringFormatting(rawCallExp); + const callExp = stringFormatCharacter + ? rawCallExp.replaceAll(stringFormatCharacter, '') + : 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 / ); ❌ ❌ ❌ + * ``` + */ + + 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 + ) || + /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, '') : '', + stringFormatCharacter + }), + side: 0 + }); + widgets.push(widget.range(from)); + } else if ( + matchingTypes.includes(type.name) && + callExp.toLowerCase().startsWith('hsl') + ) { + /** + * # 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() : '', + stringFormatCharacter + }), + side: 0 + }); + widgets.push(widget.range(from)); + } else if ( + matchingTypes.includes(type.name) && + callExp.startsWith('#') + ) { + const [color, alpha] = toFullHex(callExp); + const widget = Decoration.widget({ + widget: new ColorWidget({ + colorType: ColorType.hex, + color, + colorRaw: callExp, + from, + to, + alpha, + stringFormatCharacter + }), + 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: '', + stringFormatCharacter + }), + side: 0 + }); + widgets.push(widget.range(from)); + } + } + } + }); + } + return Decoration.set(widgets); +} + +function pickColor(dispatch: boolean) { + return function f(e: Event, view: EditorView) { + const target = e.target as HTMLInputElement; + 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) { + 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}, ${s}%, ${l}%${ + data.alpha ? `, ${data.alpha}` : '' + })`; + } + } + converted = data.stringFormatCharacter + ? `${data.stringFormatCharacter}${converted}${data.stringFormatCharacter}` + : converted; + if (dispatch) { + view.dispatch({ + changes: { + from: data.from, + to: data.to, + insert: converted + } + }); + data.to = data.from + converted.length; + } + + return true; + }; +} + +class ColorWidget extends WidgetType { + private readonly state: ColorState; + private readonly color: string; + private readonly colorRaw: string; + private wrapper?: HTMLElement; + + 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 + ); + } + updateDOM(dom: HTMLElement, view: EditorView): boolean { + dom.style.backgroundColor = this.colorRaw; + return true; + } + 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(true), + input: pickColor(true) + } + } + ); + +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..529d12de86 --- /dev/null +++ b/client/modules/IDE/components/Editor/colorpicker/utils.ts @@ -0,0 +1,96 @@ +/* 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 */ +/* eslint-disable consistent-return */ + +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) + }; +} + +export function hasStringFormatting(rawColor: string): string | undefined { + if (rawColor.startsWith("'") && rawColor.endsWith("'")) { + return "'"; + } + if (rawColor.startsWith('"') && rawColor.endsWith('"')) { + return '"'; + } + return undefined; +} 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;