Compare commits
1 Commits
wait-for-r
...
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