Merge pull request #8 from GRA0007/feat/color-picker

Color picker
This commit is contained in:
Benji Grant 2024-08-06 14:19:53 +10:00 committed by GitHub
commit 1707a2d87a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 272 additions and 43 deletions

5
.changes/color-picker.md Normal file
View file

@ -0,0 +1,5 @@
---
"obsidian-css-inlay-colors": major:feat
---
Add a color picker in live preview mode

View file

@ -0,0 +1,5 @@
---
"obsidian-css-inlay-colors": patch:fix
---
Fix the plugin crashing when trying to parse an invalid color

View file

@ -0,0 +1,5 @@
---
"obsidian-css-inlay-colors": patch:fix
---
Disable the plugin in source mode

View file

@ -7,7 +7,7 @@ on:
pull_request:
paths:
- "biome.json"
- "*.ts"
- "**/*.ts"
- "package.json"
- "tsconfig.json"
- "yarn.lock"

View file

@ -10,13 +10,7 @@ To use, just put any valid CSS color syntax in a code block like so: \`#8A5CF5\`
<img src="example.jpg" alt="Example of the extension running for all CSS color formats" width="200">
## Todo
This plugin is still in alpha. Before v1.0.0 is released I want to finish the following features:
- [x] Show colors in reading mode
- [x] Show colors in live preview mode
- [ ] Edit colors with a color picker in live preview mode
Enable the color picker setting to change a color using a color picker in live preview mode. Note that the color picker does not support opacity, and will only let you select from sRGB colors. It will attempt to preserve the existing format you have written, as well as any existing opacity.
## Development

151
src/formatColor.ts Normal file
View file

@ -0,0 +1,151 @@
import { type Color, converter, formatCss, round } from 'culori'
const twoDecimals = round(2)
const fourDecimals = round(4)
const re = /[0-9\.%]+/g
/** Format a hex the same as an existing color format */
export const formatColor = (
previousText: string,
previousColor: Color,
hex: string,
) => {
const color = converter(previousColor.mode)(hex)
if (!color) return undefined
// Copy alpha across, as the native color picker doesn't support it
color.alpha = previousColor.alpha
const hasPercentSymbol = previousText
.match(re)
?.map((match) => match.contains('%'))
// rgb
if (color.mode === 'rgb' && previousText.startsWith('rgb')) {
return replaceNumbers(previousText, [
twoDecimals(color.r * 255),
twoDecimals(color.g * 255),
twoDecimals(color.b * 255),
formatAlpha(color.alpha, hasPercentSymbol?.at(3)),
])
}
// hsl
if (color.mode === 'hsl' && previousText.startsWith('hsl')) {
return replaceNumbers(previousText, [
twoDecimals(color.h ?? 0),
percent(twoDecimals(color.s * 100), hasPercentSymbol?.at(1)),
percent(twoDecimals(color.l * 100), hasPercentSymbol?.at(2)),
formatAlpha(color.alpha, hasPercentSymbol?.at(3)),
])
}
// hwb
if (color.mode === 'hwb' && previousText.startsWith('hwb')) {
return replaceNumbers(previousText, [
twoDecimals(color.h ?? 0),
percent(twoDecimals(color.w * 100), hasPercentSymbol?.at(1)),
percent(twoDecimals(color.b * 100), hasPercentSymbol?.at(2)),
formatAlpha(color.alpha, hasPercentSymbol?.at(3)),
])
}
// lab
if (color.mode === 'lab' && previousText.startsWith('lab')) {
return replaceNumbers(previousText, [
percent(twoDecimals(color.l), hasPercentSymbol?.at(0)),
percent(twoDecimals(color.a), hasPercentSymbol?.at(1)),
percent(twoDecimals(color.b), hasPercentSymbol?.at(2)),
formatAlpha(color.alpha, hasPercentSymbol?.at(3)),
])
}
// lch
if (color.mode === 'lch' && previousText.startsWith('lch')) {
return replaceNumbers(previousText, [
percent(twoDecimals(color.l), hasPercentSymbol?.at(0)),
hasPercentSymbol?.at(1)
? percent(twoDecimals(color.c / 1.5), true)
: twoDecimals(color.c),
twoDecimals(color.h),
formatAlpha(color.alpha, hasPercentSymbol?.at(3)),
])
}
// oklab
if (color.mode === 'oklab' && previousText.startsWith('oklab')) {
return replaceNumbers(previousText, [
hasPercentSymbol?.at(0)
? percent(twoDecimals(color.l * 100), true)
: fourDecimals(color.l),
hasPercentSymbol?.at(1)
? percent(twoDecimals((color.a + 0.4) * 125), true)
: fourDecimals(color.a),
hasPercentSymbol?.at(2)
? percent(twoDecimals((color.b + 0.4) * 125), true)
: fourDecimals(color.b),
formatAlpha(color.alpha, hasPercentSymbol?.at(3)),
])
}
// oklch
if (color.mode === 'oklch' && previousText.startsWith('oklch')) {
return replaceNumbers(previousText, [
hasPercentSymbol?.at(0)
? percent(twoDecimals(color.l * 100), true)
: fourDecimals(color.l),
hasPercentSymbol?.at(1)
? percent(twoDecimals(color.c * 250), true)
: fourDecimals(color.c),
twoDecimals(color.h),
formatAlpha(color.alpha, hasPercentSymbol?.at(3)),
])
}
// hex (or named color)
if (color.mode === 'rgb') return hex
// Fallback to culori's formatter
return formatCss(color)
}
/**
* Format a numeric alpha channel, assuming it can be between 0 and 1 or a percentage.
*/
const formatAlpha = (
alpha: number | undefined,
hasPercentSymbol: boolean | undefined,
) => {
if (alpha === undefined) return
return hasPercentSymbol ? percent(alpha * 100, true) : alpha
}
/**
* Takes an existing color string and replaces the numbers without touching the formatting.
*
* ```ts
* replaceNumbers('rgb(10, 20, 30, .4)', [5, 6, 7, .8]) === 'rgb(5, 6, 7, 0.8)'
* ```
*/
const replaceNumbers = (
text: string,
numbers: (string | number | undefined | null)[],
) => {
const parts = text.split(re)
return parts.reduce((newText, part, i) => {
if (i === parts.length - 1) return `${newText}${part}`
return `${newText}${part}${numbers[i] ?? 0}`
}, '')
}
/**
* Optionally adds a percent symbol
*
* ```ts
* percent(50, false) === '50'
* percent(60, true) === '60%'
* ```
*/
const percent = (value: number | undefined, hasPercentSymbol = false) => {
if (value === undefined) return
return `${value}${hasPercentSymbol ? '%' : ''}`
}

View file

@ -8,37 +8,16 @@ import {
type ViewUpdate,
WidgetType,
} from '@codemirror/view'
import { parse } from 'culori'
import { type Color, formatHex, parse } from 'culori'
import { formatColor } from './formatColor'
class CSSColorInlayWidget extends WidgetType {
constructor(readonly color: string) {
super()
}
eq(other: CSSColorInlayWidget) {
return other.color === this.color
}
toDOM() {
const inlay = document.createElement('span')
inlay.className = 'css-color-inlay'
inlay.style.background = this.color
const wrapper = document.createElement('span')
wrapper.className = 'cm-inline-code css-color-wrapper'
wrapper.append(inlay)
return wrapper
}
}
export function inlayExtension() {
export const inlayExtension = (colorPickerEnabled: boolean) => {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet = Decoration.none
constructor(public view: EditorView) {
this.decorations = createColorWidgets(view)
this.decorations = createColorWidgets(view, colorPickerEnabled)
}
update(update: ViewUpdate) {
@ -47,7 +26,7 @@ export function inlayExtension() {
update.viewportChanged ||
syntaxTree(update.startState) !== syntaxTree(update.state)
) {
this.decorations = createColorWidgets(update.view)
this.decorations = createColorWidgets(update.view, colorPickerEnabled)
}
}
},
@ -57,7 +36,56 @@ export function inlayExtension() {
)
}
function createColorWidgets(view: EditorView) {
class CSSColorInlayWidget extends WidgetType {
constructor(
readonly text: string,
readonly color: Color,
readonly colorPickerEnabled: boolean,
readonly view: EditorView,
) {
super()
}
eq(other: CSSColorInlayWidget) {
return other.text === this.text
}
toDOM() {
const inlay = document.createElement('label')
inlay.className = 'css-color-inlay'
inlay.style.background = this.text
if (this.colorPickerEnabled) {
const input = document.createElement('input')
input.type = 'color'
input.value = formatHex(this.color)
input.addEventListener('change', (e) => {
if (!(e.currentTarget instanceof HTMLInputElement)) return
const pos = this.view.posAtDOM(wrapper)
this.view.dispatch({
changes: {
from: pos,
to: pos + this.text.length,
insert: formatColor(this.text, this.color, e.currentTarget.value),
},
})
})
inlay.append(input)
}
const wrapper = document.createElement('span')
wrapper.className = 'cm-inline-code css-color-wrapper'
wrapper.append(inlay)
return wrapper
}
}
const createColorWidgets = (view: EditorView, colorPickerEnabled: boolean) => {
// Only create widgets in live preview mode
if (!view.dom.parentElement?.classList.contains('is-live-preview'))
return Decoration.none
const widgets: Range<Decoration>[] = []
for (const { from, to } of view.visibleRanges) {
@ -66,14 +94,25 @@ function createColorWidgets(view: EditorView) {
to,
enter: (node) => {
if (node.name.includes('inline-code')) {
const color = view.state.sliceDoc(node.from, node.to)
const text = view.state.sliceDoc(node.from, node.to)
// Not a valid color
if (parse(color) === undefined) return
let color: Color | undefined
try {
color = parse(text)
if (color === undefined) return
} catch {
return
}
const deco = Decoration.widget({
side: 1,
widget: new CSSColorInlayWidget(color),
widget: new CSSColorInlayWidget(
text,
color,
colorPickerEnabled,
view,
),
})
widgets.push(deco.range(node.from))

View file

@ -4,10 +4,12 @@ import { inlayExtension } from './live'
interface CssColorsPluginSettings {
showInLiveEditor: boolean
colorPickerEnabled: boolean
}
const DEFAULT_SETTINGS: CssColorsPluginSettings = {
showInLiveEditor: true,
colorPickerEnabled: false,
}
export default class CssColorsPlugin extends Plugin {
@ -19,7 +21,9 @@ export default class CssColorsPlugin extends Plugin {
this.registerMarkdownPostProcessor(inlayPostProcessor)
if (this.settings.showInLiveEditor) {
this.registerEditorExtension(inlayExtension())
this.registerEditorExtension(
inlayExtension(this.settings.colorPickerEnabled),
)
}
this.addSettingTab(new CssColorsSettingsTab(this.app, this))
@ -50,8 +54,8 @@ class CssColorsSettingsTab extends PluginSettingTab {
containerEl.empty()
new Setting(containerEl)
.setName('Show in live editor')
.setDesc('Enable colors in the live editor (requires reload)')
.setName('Show in live preview mode')
.setDesc('Enable color inlays in the live preview (requires reload).')
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.showInLiveEditor)
@ -60,5 +64,21 @@ class CssColorsSettingsTab extends PluginSettingTab {
await this.plugin.saveSettings()
}),
)
new Setting(containerEl)
.setName('Enable the color picker')
.setDesc('Allows you to edit a color by clicking on the inlay.')
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.colorPickerEnabled)
.onChange(async (value) => {
this.plugin.settings.colorPickerEnabled = value
await this.plugin.saveSettings()
}),
)
.descEl.createDiv({
text: 'Opacity and colors outside of the sRGB color space are not supported.',
cls: 'mod-warning',
})
}
}

View file

@ -1,11 +1,15 @@
import { parse } from 'culori'
export function inlayPostProcessor(el: HTMLElement) {
export const inlayPostProcessor = (el: HTMLElement) => {
for (const code of el.findAll('code')) {
const color = code.innerText.trim()
// Not a valid color
if (parse(color) === undefined) return
try {
if (parse(color) === undefined) return
} catch {
return
}
code.createSpan({
prepend: true,

View file

@ -7,6 +7,12 @@
vertical-align: middle;
margin-right: .3em;
margin-top: -.1em;
input {
height: 0;
width: 0;
opacity: 0;
}
}
/* Make inlay colors in the live editor appear part of the code block */