mirror of
https://github.com/gra0007/obsidian-css-inlay-colors.git
synced 2026-07-22 10:40:31 +00:00
commit
1707a2d87a
10 changed files with 272 additions and 43 deletions
5
.changes/color-picker.md
Normal file
5
.changes/color-picker.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"obsidian-css-inlay-colors": major:feat
|
||||
---
|
||||
|
||||
Add a color picker in live preview mode
|
||||
5
.changes/invalid-color.md
Normal file
5
.changes/invalid-color.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"obsidian-css-inlay-colors": patch:fix
|
||||
---
|
||||
|
||||
Fix the plugin crashing when trying to parse an invalid color
|
||||
5
.changes/only-live-preview.md
Normal file
5
.changes/only-live-preview.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"obsidian-css-inlay-colors": patch:fix
|
||||
---
|
||||
|
||||
Disable the plugin in source mode
|
||||
2
.github/workflows/checks.yml
vendored
2
.github/workflows/checks.yml
vendored
|
|
@ -7,7 +7,7 @@ on:
|
|||
pull_request:
|
||||
paths:
|
||||
- "biome.json"
|
||||
- "*.ts"
|
||||
- "**/*.ts"
|
||||
- "package.json"
|
||||
- "tsconfig.json"
|
||||
- "yarn.lock"
|
||||
|
|
|
|||
|
|
@ -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
151
src/formatColor.ts
Normal 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 ? '%' : ''}`
|
||||
}
|
||||
99
src/live.ts
99
src/live.ts
|
|
@ -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))
|
||||
|
|
|
|||
26
src/main.ts
26
src/main.ts
|
|
@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Reference in a new issue