Compare commits
	
		
			1 Commits
		
	
	
		
			achalmers/
			...
			lsp-colors
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 86ae7a989f | 
| @ -834,7 +834,13 @@ test('theme persists', async ({ page, context }) => { | ||||
| }) | ||||
|  | ||||
| test.describe('code color goober', { tag: '@snapshot' }, () => { | ||||
|   test('code color goober', async ({ page, context, scene, cmdBar }) => { | ||||
|   test('code color goober', async ({ | ||||
|     page, | ||||
|     context, | ||||
|     scene, | ||||
|     cmdBar, | ||||
|     editor, | ||||
|   }) => { | ||||
|     const u = await getUtils(page) | ||||
|     await context.addInitScript(async () => { | ||||
|       localStorage.setItem( | ||||
| @ -879,6 +885,7 @@ sweepSketch = startSketchOn(XY) | ||||
|     context, | ||||
|     scene, | ||||
|     cmdBar, | ||||
|     editor, | ||||
|   }) => { | ||||
|     const u = await getUtils(page) | ||||
|     await context.addInitScript(async () => { | ||||
|  | ||||
| @ -41,6 +41,14 @@ interface LSPRequestMap { | ||||
|     LSP.DefinitionParams, | ||||
|     LSP.Definition | LSP.DefinitionLink[] | null, | ||||
|   ] | ||||
|   'textDocument/documentColor': [ | ||||
|     LSP.DocumentColorParams, | ||||
|     LSP.ColorInformation[] | null, | ||||
|   ] | ||||
|   'textDocument/colorPresentation': [ | ||||
|     LSP.ColorPresentationParams, | ||||
|     LSP.ColorPresentation[] | null, | ||||
|   ] | ||||
| } | ||||
|  | ||||
| // Client to server | ||||
| @ -229,6 +237,22 @@ export class LanguageServerClient { | ||||
|     return await this.request('textDocument/definition', params) | ||||
|   } | ||||
|  | ||||
|   async textDocumentDocumentColor(params: LSP.DocumentColorParams) { | ||||
|     const serverCapabilities = this.getServerCapabilities() | ||||
|     if (!serverCapabilities.colorProvider) { | ||||
|       return null | ||||
|     } | ||||
|     return await this.request('textDocument/documentColor', params) | ||||
|   } | ||||
|  | ||||
|   async textDocumentColorPresentation(params: LSP.ColorPresentationParams) { | ||||
|     const serverCapabilities = this.getServerCapabilities() | ||||
|     if (!serverCapabilities.colorProvider) { | ||||
|       return null | ||||
|     } | ||||
|     return await this.request('textDocument/colorPresentation', params) | ||||
|   } | ||||
|  | ||||
|   attachPlugin(plugin: LanguageServerPlugin) { | ||||
|     this.plugins.push(plugin) | ||||
|   } | ||||
|  | ||||
| @ -21,6 +21,7 @@ export { | ||||
|   lspRenameEvent, | ||||
|   lspSemanticTokensEvent, | ||||
|   lspCodeActionEvent, | ||||
|   lspColorUpdateEvent, | ||||
| } from './plugin/annotation' | ||||
| export { | ||||
|   LanguageServerPlugin, | ||||
|  | ||||
| @ -6,6 +6,7 @@ export enum LspAnnotation { | ||||
|   Diagnostics = 'diagnostics', | ||||
|   Rename = 'rename', | ||||
|   CodeAction = 'code-action', | ||||
|   ColorUpdate = 'color-update', | ||||
| } | ||||
|  | ||||
| const lspEvent = Annotation.define<LspAnnotation>() | ||||
| @ -14,3 +15,4 @@ export const lspFormatCodeEvent = lspEvent.of(LspAnnotation.FormatCode) | ||||
| export const lspDiagnosticsEvent = lspEvent.of(LspAnnotation.Diagnostics) | ||||
| export const lspRenameEvent = lspEvent.of(LspAnnotation.Rename) | ||||
| export const lspCodeActionEvent = lspEvent.of(LspAnnotation.CodeAction) | ||||
| export const lspColorUpdateEvent = lspEvent.of(LspAnnotation.ColorUpdate) | ||||
|  | ||||
							
								
								
									
										278
									
								
								packages/codemirror-lsp-client/src/plugin/colors.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										278
									
								
								packages/codemirror-lsp-client/src/plugin/colors.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,278 @@ | ||||
| import { | ||||
|   StateEffect, | ||||
|   StateField, | ||||
|   type Extension, | ||||
|   type Range, | ||||
| } from '@codemirror/state' | ||||
| import { | ||||
|   Decoration, | ||||
|   type DecorationSet, | ||||
|   EditorView, | ||||
|   ViewPlugin, | ||||
|   WidgetType, | ||||
|   type ViewUpdate, | ||||
| } from '@codemirror/view' | ||||
|  | ||||
| import type { LanguageServerPlugin } from './lsp' | ||||
| import { lspColorUpdateEvent } from './annotation' | ||||
| import { isArray } from '../lib/utils' | ||||
| import { offsetToPos, posToOffset, posToOffsetOrZero } from './util' | ||||
| import type * as LSP from 'vscode-languageserver-protocol' | ||||
|  | ||||
| /* ------------------------------------------------------------------ */ | ||||
| /* ----------  original helpers / widget / color utilities  ---------- */ | ||||
| /* ------------------------------------------------------------------ */ | ||||
|  | ||||
| interface PickerState { | ||||
|   from: number | ||||
|   to: number | ||||
|   red: number | ||||
|   green: number | ||||
|   blue: number | ||||
|   alpha: number | ||||
| } | ||||
|  | ||||
| export interface WidgetOptions extends PickerState { | ||||
|   color: string | ||||
| } | ||||
|  | ||||
| export type ColorData = Omit<WidgetOptions, 'from' | 'to'> | ||||
|  | ||||
| const pickerState = new WeakMap<HTMLInputElement, PickerState>() | ||||
|  | ||||
| function rgbaToHex(color: LSP.Color): string { | ||||
|   return ( | ||||
|     '#' + | ||||
|     [color.red, color.green, color.blue] | ||||
|       .map((c) => | ||||
|         Math.round(c * 255) | ||||
|           .toString(16) | ||||
|           .padStart(2, '0') | ||||
|       ) | ||||
|       .join('') | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function hexToRGBComponents(hex: string): number[] { | ||||
|   const r = hex.slice(1, 3) | ||||
|   const g = hex.slice(3, 5) | ||||
|   const b = hex.slice(5, 7) | ||||
|   return [parseInt(r, 16) / 255, parseInt(g, 16) / 255, parseInt(b, 16) / 255] | ||||
| } | ||||
|  | ||||
| async function discoverColorsViaLsp( | ||||
|   view: EditorView, | ||||
|   plugin: LanguageServerPlugin | ||||
| ): Promise<WidgetOptions | Array<WidgetOptions> | null> { | ||||
|   const responses = await plugin.requestDocumentColors() | ||||
|   if (!responses) return null | ||||
|  | ||||
|   const colors: Array<WidgetOptions> = [] | ||||
|   for (const color of responses) { | ||||
|     if (!color.range || !color.color) continue | ||||
|  | ||||
|     const { start, end } = color.range | ||||
|     const from = posToOffset(view.state.doc, start) | ||||
|     const to = posToOffset(view.state.doc, end) | ||||
|     if (from == null || to == null) continue | ||||
|  | ||||
|     colors.push({ | ||||
|       color: rgbaToHex(color.color), | ||||
|       ...color.color, | ||||
|       from, | ||||
|       to, | ||||
|     }) | ||||
|   } | ||||
|   return colors | ||||
| } | ||||
|  | ||||
| async function colorPickersDecorations( | ||||
|   view: EditorView, | ||||
|   plugin: LanguageServerPlugin | ||||
| ): Promise<DecorationSet> { | ||||
|   const widgets: Array<Range<Decoration>> = [] | ||||
|   const maybe = await discoverColorsViaLsp(view, plugin) | ||||
|   if (!maybe) return Decoration.none | ||||
|  | ||||
|   const optionsList = isArray(maybe) ? maybe : [maybe] | ||||
|   for (const wo of optionsList) { | ||||
|     widgets.push( | ||||
|       Decoration.widget({ | ||||
|         widget: new ColorPickerWidget(wo), | ||||
|         side: 1, | ||||
|       }).range(wo.from) | ||||
|     ) | ||||
|   } | ||||
|   return Decoration.set(widgets) | ||||
| } | ||||
|  | ||||
| export const wrapperClassName = 'cm-css-color-picker-wrapper' | ||||
|  | ||||
| class ColorPickerWidget extends WidgetType { | ||||
|   private readonly state: PickerState | ||||
|   private readonly color: string | ||||
|  | ||||
|   constructor({ color, ...state }: WidgetOptions) { | ||||
|     super() | ||||
|     this.state = state | ||||
|     this.color = color | ||||
|   } | ||||
|  | ||||
|   eq(other: ColorPickerWidget) { | ||||
|     return ( | ||||
|       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') | ||||
|     pickerState.set(picker, this.state) | ||||
|     picker.type = 'color' | ||||
|     picker.value = this.color | ||||
|  | ||||
|     const wrapper = document.createElement('span') | ||||
|     wrapper.appendChild(picker) | ||||
|     wrapper.className = wrapperClassName | ||||
|     return wrapper | ||||
|   } | ||||
|  | ||||
|   ignoreEvent() { | ||||
|     return false | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const colorPickerTheme = EditorView.baseTheme({ | ||||
|   [`.${wrapperClassName}`]: { | ||||
|     display: 'inline-block', | ||||
|     outline: '1px solid #eee', | ||||
|     marginRight: '0.6ch', | ||||
|     height: '1em', | ||||
|     width: '1em', | ||||
|     transform: 'translateY(1px)', | ||||
|   }, | ||||
|   [`.${wrapperClassName} input[type="color"]`]: { | ||||
|     cursor: 'pointer', | ||||
|     height: '100%', | ||||
|     width: '100%', | ||||
|     padding: 0, | ||||
|     border: 'none', | ||||
|     '&::-webkit-color-swatch-wrapper': { padding: 0 }, | ||||
|     '&::-webkit-color-swatch': { border: 'none' }, | ||||
|     '&::-moz-color-swatch': { border: 'none' }, | ||||
|   }, | ||||
| }) | ||||
|  | ||||
| /* ------------------------------------------------------------------ */ | ||||
| /* -------------------  ✅  new state machinery  -------------------- */ | ||||
| /* ------------------------------------------------------------------ */ | ||||
|  | ||||
| // Effect that carries a fresh DecorationSet | ||||
| const setColorDecorations = StateEffect.define<DecorationSet>() | ||||
|  | ||||
| // Field that stores the current DecorationSet | ||||
| const colorDecorationsField = StateField.define<DecorationSet>({ | ||||
|   create: () => Decoration.none, | ||||
|   update(value, tr) { | ||||
|     value = value.map(tr.changes) | ||||
|     for (const e of tr.effects) if (e.is(setColorDecorations)) value = e.value | ||||
|     return value | ||||
|   }, | ||||
|   provide: (f) => EditorView.decorations.from(f), | ||||
| }) | ||||
|  | ||||
| /* ------------------------------------------------------------------ */ | ||||
| /* ------------------  original ViewPlugin, patched  ---------------- */ | ||||
| /* ------------------------------------------------------------------ */ | ||||
|  | ||||
| export const makeColorPicker = (plugin: ViewPlugin<LanguageServerPlugin>) => | ||||
|   ViewPlugin.fromClass( | ||||
|     class ColorPickerViewPlugin { | ||||
|       plugin: LanguageServerPlugin | null | ||||
|  | ||||
|       constructor(view: EditorView) { | ||||
|         this.plugin = view.plugin(plugin) | ||||
|         if (!this.plugin) return | ||||
|  | ||||
|         // initial async load → dispatch decorations | ||||
|         // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|         colorPickersDecorations(view, this.plugin).then((deco) => { | ||||
|           view.dispatch({ effects: setColorDecorations.of(deco) }) | ||||
|         }) | ||||
|       } | ||||
|  | ||||
|       async update(update: ViewUpdate) { | ||||
|         if (!this.plugin) return | ||||
|         if (!(update.docChanged || update.viewportChanged)) return | ||||
|  | ||||
|         const deco = await colorPickersDecorations(update.view, this.plugin) | ||||
|         update.view.dispatch({ effects: setColorDecorations.of(deco) }) | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       eventHandlers: { | ||||
|         change: (e: Event, view: EditorView) => { | ||||
|           // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|           colorPickerChange(e, view, plugin) | ||||
|         }, | ||||
|       }, | ||||
|     } | ||||
|   ) | ||||
|  | ||||
| /* ------------------------------------------------------------------ */ | ||||
| /* --------------------  unchanged event handler  ------------------- */ | ||||
| /* ------------------------------------------------------------------ */ | ||||
|  | ||||
| async function colorPickerChange( | ||||
|   e: Event, | ||||
|   view: EditorView, | ||||
|   plugin: ViewPlugin<LanguageServerPlugin> | ||||
| ): Promise<boolean> { | ||||
|   const value = view.plugin(plugin) | ||||
|   if (!value) return false | ||||
|  | ||||
|   const target = e.target as HTMLInputElement | ||||
|   if ( | ||||
|     target.nodeName !== 'INPUT' || | ||||
|     !target.parentElement?.classList.contains(wrapperClassName) | ||||
|   ) | ||||
|     return false | ||||
|  | ||||
|   const data = pickerState.get(target)! | ||||
|   const converted = target.value + data.alpha | ||||
|   const [red, green, blue] = hexToRGBComponents(converted) | ||||
|  | ||||
|   const responses = await value.requestColorPresentation( | ||||
|     { red, green, blue, alpha: data.alpha }, | ||||
|     { | ||||
|       start: offsetToPos(view.state.doc, data.from), | ||||
|       end: offsetToPos(view.state.doc, data.to), | ||||
|     } | ||||
|   ) | ||||
|   if (!responses?.length) return false | ||||
|  | ||||
|   for (const resp of responses) { | ||||
|     const changes = resp.textEdit | ||||
|       ? { | ||||
|           from: posToOffsetOrZero(view.state.doc, resp.textEdit.range.start), | ||||
|           to: posToOffsetOrZero(view.state.doc, resp.textEdit.range.end), | ||||
|           insert: resp.textEdit.newText, | ||||
|         } | ||||
|       : { from: data.from, to: data.to, insert: resp.label } | ||||
|  | ||||
|     view.dispatch({ changes, annotations: [lspColorUpdateEvent] }) | ||||
|   } | ||||
|   return true | ||||
| } | ||||
|  | ||||
| /* ------------------------------------------------------------------ */ | ||||
| /* -------------------------  public API  --------------------------- */ | ||||
| /* ------------------------------------------------------------------ */ | ||||
|  | ||||
| export default function lspColorsExt( | ||||
|   plugin: ViewPlugin<LanguageServerPlugin> | ||||
| ): Extension { | ||||
|   return [colorDecorationsField, makeColorPicker(plugin), colorPickerTheme] | ||||
| } | ||||
| @ -48,6 +48,7 @@ import { isArray } from '../lib/utils' | ||||
| import lspGoToDefinitionExt from './go-to-definition' | ||||
| import lspRenameExt from './rename' | ||||
| import lspSignatureHelpExt from './signature-help' | ||||
| import lspColorsExt from './colors' | ||||
|  | ||||
| const useLast = (values: readonly any[]) => values.reduce((_, v) => v, '') | ||||
| export const docPathFacet = Facet.define<string, string>({ | ||||
| @ -534,6 +535,37 @@ export class LanguageServerPlugin implements PluginValue { | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   async requestDocumentColors() { | ||||
|     if ( | ||||
|       !(this.client.getServerCapabilities().colorProvider && this.client.ready) | ||||
|     ) { | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     const result = await this.client.textDocumentDocumentColor({ | ||||
|       textDocument: { uri: this.getDocUri() }, | ||||
|     }) | ||||
|  | ||||
|     if (!result) return | ||||
|     return result | ||||
|   } | ||||
|  | ||||
|   async requestColorPresentation(color: LSP.Color, range: LSP.Range) { | ||||
|     if ( | ||||
|       !(this.client.getServerCapabilities().colorProvider && this.client.ready) | ||||
|     ) { | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     const result = await this.client.textDocumentColorPresentation({ | ||||
|       textDocument: { uri: this.getDocUri() }, | ||||
|       color, | ||||
|       range, | ||||
|     }) | ||||
|     if (!result) return | ||||
|     return result | ||||
|   } | ||||
|  | ||||
|   async requestRename( | ||||
|     view: EditorView, | ||||
|     { line, character }: { line: number; character: number } | ||||
| @ -1318,6 +1350,7 @@ export class LanguageServerPluginSpec | ||||
|     return [ | ||||
|       linter(null), | ||||
|       lspAutocompleteExt(plugin), | ||||
|       lspColorsExt(plugin), | ||||
|       lspFormatExt(plugin), | ||||
|       lspGoToDefinitionExt(plugin), | ||||
|       lspHoverExt(plugin), | ||||
|  | ||||
							
								
								
									
										14
									
								
								rust/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										14
									
								
								rust/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @ -535,7 +535,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" | ||||
| dependencies = [ | ||||
|  "lazy_static", | ||||
|  "windows-sys 0.52.0", | ||||
|  "windows-sys 0.59.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @ -963,7 +963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" | ||||
| dependencies = [ | ||||
|  "libc", | ||||
|  "windows-sys 0.52.0", | ||||
|  "windows-sys 0.59.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @ -1746,7 +1746,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" | ||||
| dependencies = [ | ||||
|  "hermit-abi", | ||||
|  "libc", | ||||
|  "windows-sys 0.52.0", | ||||
|  "windows-sys 0.59.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @ -2987,7 +2987,7 @@ dependencies = [ | ||||
|  "once_cell", | ||||
|  "socket2", | ||||
|  "tracing", | ||||
|  "windows-sys 0.52.0", | ||||
|  "windows-sys 0.59.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @ -3306,7 +3306,7 @@ dependencies = [ | ||||
|  "errno", | ||||
|  "libc", | ||||
|  "linux-raw-sys", | ||||
|  "windows-sys 0.52.0", | ||||
|  "windows-sys 0.59.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @ -3900,7 +3900,7 @@ dependencies = [ | ||||
|  "getrandom 0.3.1", | ||||
|  "once_cell", | ||||
|  "rustix", | ||||
|  "windows-sys 0.52.0", | ||||
|  "windows-sys 0.59.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @ -4753,7 +4753,7 @@ version = "0.1.9" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" | ||||
| dependencies = [ | ||||
|  "windows-sys 0.52.0", | ||||
|  "windows-sys 0.59.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
|  | ||||
| @ -4220,8 +4220,8 @@ sketch001 = startSketchOn(XY) | ||||
|         result, | ||||
|         vec![tower_lsp::lsp_types::ColorInformation { | ||||
|             range: tower_lsp::lsp_types::Range { | ||||
|                 start: tower_lsp::lsp_types::Position { line: 4, character: 24 }, | ||||
|                 end: tower_lsp::lsp_types::Position { line: 4, character: 33 }, | ||||
|                 start: tower_lsp::lsp_types::Position { line: 4, character: 25 }, | ||||
|                 end: tower_lsp::lsp_types::Position { line: 4, character: 32 }, | ||||
|             }, | ||||
|             color: tower_lsp::lsp_types::Color { | ||||
|                 red: 1.0, | ||||
| @ -4272,8 +4272,8 @@ sketch001 = startSketchOn(XY) | ||||
|         result, | ||||
|         vec![tower_lsp::lsp_types::ColorInformation { | ||||
|             range: tower_lsp::lsp_types::Range { | ||||
|                 start: tower_lsp::lsp_types::Position { line: 4, character: 24 }, | ||||
|                 end: tower_lsp::lsp_types::Position { line: 4, character: 33 }, | ||||
|                 start: tower_lsp::lsp_types::Position { line: 4, character: 25 }, | ||||
|                 end: tower_lsp::lsp_types::Position { line: 4, character: 32 }, | ||||
|             }, | ||||
|             color: tower_lsp::lsp_types::Color { | ||||
|                 red: 1.0, | ||||
| @ -4291,8 +4291,8 @@ sketch001 = startSketchOn(XY) | ||||
|                 uri: "file:///test.kcl".try_into().unwrap(), | ||||
|             }, | ||||
|             range: tower_lsp::lsp_types::Range { | ||||
|                 start: tower_lsp::lsp_types::Position { line: 4, character: 24 }, | ||||
|                 end: tower_lsp::lsp_types::Position { line: 4, character: 33 }, | ||||
|                 start: tower_lsp::lsp_types::Position { line: 4, character: 25 }, | ||||
|                 end: tower_lsp::lsp_types::Position { line: 4, character: 32 }, | ||||
|             }, | ||||
|             color: tower_lsp::lsp_types::Color { | ||||
|                 red: 1.0, | ||||
|  | ||||
| @ -438,8 +438,15 @@ impl Node<Program> { | ||||
|         let add_color = |literal: &Node<Literal>| { | ||||
|             // Check if the string is a color. | ||||
|             if let Some(c) = literal.value.is_color() { | ||||
|                 let source_range = literal.as_source_range(); | ||||
|                 // We subtract 1 from either side because of the "'s in the literal. | ||||
|                 let fixed_source_range = SourceRange::new( | ||||
|                     source_range.start() + 1, | ||||
|                     source_range.end() - 1, | ||||
|                     source_range.module_id(), | ||||
|                 ); | ||||
|                 let color = ColorInformation { | ||||
|                     range: literal.as_source_range().to_lsp_range(code), | ||||
|                     range: fixed_source_range.to_lsp_range(code), | ||||
|                     color: tower_lsp::lsp_types::Color { | ||||
|                         red: c.r, | ||||
|                         green: c.g, | ||||
| @ -498,7 +505,11 @@ impl Node<Program> { | ||||
|         crate::walk::walk(self, |node: crate::walk::Node<'a>| { | ||||
|             match node { | ||||
|                 crate::walk::Node::Literal(literal) => { | ||||
|                     if literal.start == pos_start && literal.end == pos_end && literal.value.is_color().is_some() { | ||||
|                     // Account for the quotes in the literal. | ||||
|                     if (literal.start + 1) == pos_start | ||||
|                         && (literal.end - 1) == pos_end | ||||
|                         && literal.value.is_color().is_some() | ||||
|                     { | ||||
|                         found.replace(true); | ||||
|                         return Ok(true); | ||||
|                     } | ||||
|  | ||||
| @ -1,328 +0,0 @@ | ||||
| import { language, syntaxTree } from '@codemirror/language' | ||||
| import type { Extension, Range, Text } from '@codemirror/state' | ||||
| import type { DecorationSet, ViewUpdate } from '@codemirror/view' | ||||
| import { | ||||
|   Decoration, | ||||
|   EditorView, | ||||
|   ViewPlugin, | ||||
|   WidgetType, | ||||
| } from '@codemirror/view' | ||||
| import type { Tree } from '@lezer/common' | ||||
| import { NodeProp } from '@lezer/common' | ||||
| import { isArray } from '@src/lib/utils' | ||||
|  | ||||
| interface PickerState { | ||||
|   from: number | ||||
|   to: number | ||||
|   alpha: string | ||||
|   colorType: ColorType | ||||
| } | ||||
|  | ||||
| export interface WidgetOptions extends PickerState { | ||||
|   color: string | ||||
| } | ||||
|  | ||||
| export type ColorData = Omit<WidgetOptions, 'from' | 'to'> | ||||
|  | ||||
| const pickerState = new WeakMap<HTMLInputElement, PickerState>() | ||||
|  | ||||
| export enum ColorType { | ||||
|   hex = 'HEX', | ||||
| } | ||||
|  | ||||
| const hexRegex = /(^|\b)(#[0-9a-f]{3,9})(\b|$)/i | ||||
|  | ||||
| function discoverColorsInKCL( | ||||
|   syntaxTree: Tree, | ||||
|   from: number, | ||||
|   to: number, | ||||
|   typeName: string, | ||||
|   doc: Text, | ||||
|   language?: string | ||||
| ): WidgetOptions | Array<WidgetOptions> | null { | ||||
|   switch (typeName) { | ||||
|     case 'Program': | ||||
|     case 'VariableDeclaration': | ||||
|     case 'CallExpressionKw': | ||||
|     case 'ObjectExpression': | ||||
|     case 'ObjectProperty': | ||||
|     case 'ArgumentList': | ||||
|     case 'PipeExpression': { | ||||
|       let innerTree = syntaxTree.resolveInner(from, 0).tree | ||||
|  | ||||
|       if (!innerTree) { | ||||
|         innerTree = syntaxTree.resolveInner(from, 1).tree | ||||
|         if (!innerTree) { | ||||
|           return null | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       const overlayTree = innerTree.prop(NodeProp.mounted)?.tree | ||||
|  | ||||
|       if (overlayTree?.type.name !== 'Styles') { | ||||
|         return null | ||||
|       } | ||||
|  | ||||
|       const ret: Array<WidgetOptions> = [] | ||||
|       overlayTree.iterate({ | ||||
|         from: 0, | ||||
|         to: overlayTree.length, | ||||
|         enter: ({ type, from: overlayFrom, to: overlayTo }) => { | ||||
|           const maybeWidgetOptions = discoverColorsInKCL( | ||||
|             syntaxTree, | ||||
|             // We add one because the tree doesn't include the | ||||
|             // quotation mark from the style tag | ||||
|             from + 1 + overlayFrom, | ||||
|             from + 1 + overlayTo, | ||||
|             type.name, | ||||
|             doc, | ||||
|             language | ||||
|           ) | ||||
|  | ||||
|           if (maybeWidgetOptions) { | ||||
|             if (isArray(maybeWidgetOptions)) { | ||||
|               console.error('Unexpected nested overlays') | ||||
|               ret.push(...maybeWidgetOptions) | ||||
|             } else { | ||||
|               ret.push(maybeWidgetOptions) | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|       }) | ||||
|  | ||||
|       return ret | ||||
|     } | ||||
|  | ||||
|     case 'String': { | ||||
|       const result = parseColorLiteral(doc.sliceString(from, to)) | ||||
|       if (!result) { | ||||
|         return null | ||||
|       } | ||||
|       return { | ||||
|         ...result, | ||||
|         from, | ||||
|         to, | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     default: | ||||
|       return null | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function parseColorLiteral(colorLiteral: string): ColorData | null { | ||||
|   const literal = colorLiteral.replace(/"/g, '') | ||||
|   const match = hexRegex.exec(literal) | ||||
|   if (!match) { | ||||
|     return null | ||||
|   } | ||||
|   const [color, alpha] = toFullHex(literal) | ||||
|  | ||||
|   return { | ||||
|     colorType: ColorType.hex, | ||||
|     color, | ||||
|     alpha, | ||||
|   } | ||||
| } | ||||
|  | ||||
| function colorPickersDecorations( | ||||
|   view: EditorView, | ||||
|   discoverColors: typeof discoverColorsInKCL | ||||
| ) { | ||||
|   const widgets: Array<Range<Decoration>> = [] | ||||
|  | ||||
|   const st = syntaxTree(view.state) | ||||
|  | ||||
|   for (const range of view.visibleRanges) { | ||||
|     st.iterate({ | ||||
|       from: range.from, | ||||
|       to: range.to, | ||||
|       enter: ({ type, from, to }) => { | ||||
|         const maybeWidgetOptions = discoverColors( | ||||
|           st, | ||||
|           from, | ||||
|           to, | ||||
|           type.name, | ||||
|           view.state.doc, | ||||
|           view.state.facet(language)?.name | ||||
|         ) | ||||
|  | ||||
|         if (!maybeWidgetOptions) { | ||||
|           return | ||||
|         } | ||||
|  | ||||
|         if (!isArray(maybeWidgetOptions)) { | ||||
|           widgets.push( | ||||
|             Decoration.widget({ | ||||
|               widget: new ColorPickerWidget(maybeWidgetOptions), | ||||
|               side: 1, | ||||
|             }).range(maybeWidgetOptions.from) | ||||
|           ) | ||||
|  | ||||
|           return | ||||
|         } | ||||
|  | ||||
|         for (const wo of maybeWidgetOptions) { | ||||
|           widgets.push( | ||||
|             Decoration.widget({ | ||||
|               widget: new ColorPickerWidget(wo), | ||||
|               side: 1, | ||||
|             }).range(wo.from) | ||||
|           ) | ||||
|         } | ||||
|       }, | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   return Decoration.set(widgets) | ||||
| } | ||||
|  | ||||
| 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, ''] | ||||
| } | ||||
|  | ||||
| export const wrapperClassName = 'cm-css-color-picker-wrapper' | ||||
|  | ||||
| class ColorPickerWidget extends WidgetType { | ||||
|   private readonly state: PickerState | ||||
|   private readonly color: string | ||||
|  | ||||
|   constructor({ color, ...state }: WidgetOptions) { | ||||
|     super() | ||||
|     this.state = state | ||||
|     this.color = color | ||||
|   } | ||||
|  | ||||
|   eq(other: ColorPickerWidget) { | ||||
|     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') | ||||
|     pickerState.set(picker, this.state) | ||||
|     picker.type = 'color' | ||||
|     picker.value = this.color | ||||
|  | ||||
|     const wrapper = document.createElement('span') | ||||
|     wrapper.appendChild(picker) | ||||
|     wrapper.className = wrapperClassName | ||||
|  | ||||
|     return wrapper | ||||
|   } | ||||
|  | ||||
|   ignoreEvent() { | ||||
|     return false | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const colorPickerTheme = EditorView.baseTheme({ | ||||
|   [`.${wrapperClassName}`]: { | ||||
|     display: 'inline-block', | ||||
|     outline: '1px solid #eee', | ||||
|     marginRight: '0.6ch', | ||||
|     height: '1em', | ||||
|     width: '1em', | ||||
|     transform: 'translateY(1px)', | ||||
|   }, | ||||
|   [`.${wrapperClassName} input[type="color"]`]: { | ||||
|     cursor: 'pointer', | ||||
|     height: '100%', | ||||
|     width: '100%', | ||||
|     padding: 0, | ||||
|     border: 'none', | ||||
|     '&::-webkit-color-swatch-wrapper': { | ||||
|       padding: 0, | ||||
|     }, | ||||
|     '&::-webkit-color-swatch': { | ||||
|       border: 'none', | ||||
|     }, | ||||
|     '&::-moz-color-swatch': { | ||||
|       border: 'none', | ||||
|     }, | ||||
|   }, | ||||
| }) | ||||
|  | ||||
| interface IFactoryOptions { | ||||
|   discoverColors: typeof discoverColorsInKCL | ||||
| } | ||||
|  | ||||
| export const makeColorPicker = (options: IFactoryOptions) => | ||||
|   ViewPlugin.fromClass( | ||||
|     class ColorPickerViewPlugin { | ||||
|       decorations: DecorationSet | ||||
|  | ||||
|       constructor(view: EditorView) { | ||||
|         this.decorations = colorPickersDecorations(view, options.discoverColors) | ||||
|       } | ||||
|  | ||||
|       update(update: ViewUpdate) { | ||||
|         if (update.docChanged || update.viewportChanged) { | ||||
|           this.decorations = colorPickersDecorations( | ||||
|             update.view, | ||||
|             options.discoverColors | ||||
|           ) | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       decorations: (v) => v.decorations, | ||||
|       eventHandlers: { | ||||
|         change: (e, view) => { | ||||
|           const target = e.target as HTMLInputElement | ||||
|           if ( | ||||
|             target.nodeName !== 'INPUT' || | ||||
|             !target.parentElement || | ||||
|             !target.parentElement.classList.contains(wrapperClassName) | ||||
|           ) { | ||||
|             return false | ||||
|           } | ||||
|  | ||||
|           const data = pickerState.get(target)! | ||||
|  | ||||
|           let converted = '"' + target.value + data.alpha + '"' | ||||
|  | ||||
|           view.dispatch({ | ||||
|             changes: { | ||||
|               from: data.from, | ||||
|               to: data.to, | ||||
|               insert: converted, | ||||
|             }, | ||||
|           }) | ||||
|  | ||||
|           return true | ||||
|         }, | ||||
|       }, | ||||
|     } | ||||
|   ) | ||||
|  | ||||
| export const colorPicker: Extension = [ | ||||
|   makeColorPicker({ discoverColors: discoverColorsInKCL }), | ||||
|   colorPickerTheme, | ||||
| ] | ||||
| @ -7,6 +7,7 @@ import type { | ||||
| } from '@kittycad/codemirror-lsp-client' | ||||
| import { | ||||
|   lspCodeActionEvent, | ||||
|   lspColorUpdateEvent, | ||||
|   lspFormatCodeEvent, | ||||
|   lspPlugin, | ||||
|   lspRenameEvent, | ||||
| @ -88,6 +89,8 @@ export class KclPlugin implements PluginValue { | ||||
|         isRelevant = true | ||||
|       } else if (tr.annotation(lspCodeActionEvent.type)) { | ||||
|         isRelevant = true | ||||
|       } else if (tr.annotation(lspColorUpdateEvent.type)) { | ||||
|         isRelevant = true | ||||
|       } | ||||
|  | ||||
|       // Don't make this an else. | ||||
|  | ||||
| @ -8,7 +8,6 @@ import type { | ||||
| import type * as LSP from 'vscode-languageserver-protocol' | ||||
|  | ||||
| import { kclPlugin } from '@src/editor/plugins/lsp/kcl' | ||||
| import { colorPicker } from '@src/editor/plugins/lsp/kcl/colors' | ||||
|  | ||||
| export interface LanguageOptions { | ||||
|   workspaceFolders: LSP.WorkspaceFolder[] | ||||
| @ -22,7 +21,6 @@ export interface LanguageOptions { | ||||
|  | ||||
| export function kcl(options: LanguageOptions) { | ||||
|   return new LanguageSupport(KclLanguage, [ | ||||
|     colorPicker, | ||||
|     kclPlugin({ | ||||
|       documentUri: options.documentUri, | ||||
|       workspaceFolders: options.workspaceFolders, | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	