diff --git a/src/editor/editorExtension.ts b/src/editor/editorExtension.ts index 2133e33..2198d29 100644 --- a/src/editor/editorExtension.ts +++ b/src/editor/editorExtension.ts @@ -8,138 +8,137 @@ import { MatchDecorator, PluginValue, } from '@codemirror/view'; -import { RangeSet } from '@codemirror/state'; import { hasGoodContrast } from '../utils/colorParser'; import type { ColorPreviewSettings } from '../types'; -// Combined color regex -// MatchDecorator needs its own RegExp -// instance (it mutates lastIndex internally). +// Regex source const HEX_SRC = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9a-fA-F])/; const RGB_SRC = /rgba?\(\s*(?:25[0-5]|2[0-4]\d|1?\d{1,2})\s*,\s*(?:25[0-5]|2[0-4]\d|1?\d{1,2})\s*,\s*(?:25[0-5]|2[0-4]\d|1?\d{1,2})(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)/; const HSL_SRC = /hsla?\(\s*(?:36[0]|3[0-5]\d|[12]?\d{1,2})\s*,\s*(?:100|\d{1,2})%\s*,\s*(?:100|\d{1,2})%(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)/; const COMBINED_SRC = [HEX_SRC, RGB_SRC, HSL_SRC].map(r => r.source).join('|'); +// ColorWidget +class ColorWidget extends WidgetType { + constructor( + readonly color: string, + readonly originalText: string, + readonly showSwatch: boolean, + readonly colorizeText: boolean, + ) { super(); } -// Swatch widget -class ColorSwatchWidget extends WidgetType { - constructor(readonly color: string) { super(); } - - toDOM(): HTMLElement { - const el = document.createElement('span'); - el.className = 'cp-color-swatch'; - el.style.backgroundColor = this.color; - el.setAttribute('aria-label', `Color: ${this.color}`); - return el; + eq(other: ColorWidget): boolean { + return ( + this.color === other.color && + this.originalText === other.originalText && + this.showSwatch === other.showSwatch && + this.colorizeText === other.colorizeText + ); } - eq(other: ColorSwatchWidget): boolean { return this.color === other.color; } - ignoreEvent(): boolean { return true; } + toDOM(): HTMLElement { + const wrapper = document.createElement('span'); + wrapper.className = 'cp-color-inline'; + // Let CM6 know this widget represents the replaced text, for a11y + wrapper.setAttribute('aria-label', `Color: ${this.color}`); + + if (this.showSwatch) { + const swatch = document.createElement('span'); + swatch.className = 'cp-color-swatch'; + swatch.style.backgroundColor = this.color; + wrapper.appendChild(swatch); + } + + const label = document.createElement('span'); + label.textContent = this.originalText; + + const canColorize = this.colorizeText && hasGoodContrast(this.color); + if (canColorize) { + label.className = 'cp-colored-text'; + label.style.color = this.color; + } + + wrapper.appendChild(label); + return wrapper; + } + + ignoreEvent(): boolean { return false; } } - -// MatchDecorator factories -function makeSwatchDecorator(): MatchDecorator { +// MatchDecorator factory +function makeDecorator(settings: ColorPreviewSettings): MatchDecorator { return new MatchDecorator({ regexp: new RegExp(COMBINED_SRC, 'gi'), - decoration: (match) => - Decoration.widget({ - widget: new ColorSwatchWidget(match[0]), - side: -1, // insert before the matched text - }), - }); -} - -function makeMarkDecorator(): MatchDecorator { - return new MatchDecorator({ - regexp: new RegExp(COMBINED_SRC, 'gi'), - // Return null (cast) to skip colors with poor contrast decoration: (match) => { const color = match[0]; - if (!hasGoodContrast(color)) return null as unknown as Decoration; - return Decoration.mark({ - class: 'cp-colored-text', - attributes: { style: `color: ${color} !important;`, 'data-color': color }, + return Decoration.replace({ + widget: new ColorWidget( + color, + match[0], + settings.showSwatchInEditor, + settings.colorizeTextInEditor, + ), }); }, }); } - // ViewPlugin class ColorPreviewPlugin implements PluginValue { decorations: DecorationSet; - - private swatchDeco: MatchDecorator | null = null; - private markDeco: MatchDecorator | null = null; - private swatchSet: DecorationSet = Decoration.none; - private markSet: DecorationSet = Decoration.none; - private lastSettingsKey = ''; + private decorator: MatchDecorator; + private lastSettingsKey: string; constructor( private readonly view: EditorView, private readonly getSettings: () => ColorPreviewSettings, ) { - this.initialize(view); - this.decorations = this.merged(); + const s = this.getSettings(); + this.lastSettingsKey = this.settingsKey(s); + this.decorator = makeDecorator(s); + this.decorations = this.decorator.createDeco(view); } update(update: ViewUpdate): void { const s = this.getSettings(); - const key = `${s.showSwatchInEditor}|${s.colorizeTextInEditor}`; - const settingsChanged = key !== this.lastSettingsKey; + const key = this.settingsKey(s); - if (settingsChanged) { - // Settings changed — recreate decorators and rebuild from scratch. - this.initialize(update.view); + if (key !== this.lastSettingsKey) { + // Settings changed — new decorator needed (widget params changed) + this.lastSettingsKey = key; + this.decorator = makeDecorator(s); + this.decorations = this.decorator.createDeco(update.view); } else if (update.docChanged || update.viewportChanged) { - // Incremental update — MatchDecorator.updateDeco() only rescans - // changed/newly visible ranges, reusing everything else. - if (this.swatchDeco) { - this.swatchSet = this.swatchDeco.updateDeco(update, this.swatchSet); - } - if (this.markDeco) { - this.markSet = this.markDeco.updateDeco(update, this.markSet); - } - } else { - return; // nothing to do + // Incremental update — reuse existing decorations where unchanged + this.decorations = this.decorator.updateDeco(update, this.decorations); } - - this.decorations = this.merged(); } - private initialize(view: EditorView): void { - const s = this.getSettings(); - this.lastSettingsKey = `${s.showSwatchInEditor}|${s.colorizeTextInEditor}`; - - this.swatchDeco = s.showSwatchInEditor ? makeSwatchDecorator() : null; - this.markDeco = s.colorizeTextInEditor ? makeMarkDecorator() : null; - - this.swatchSet = this.swatchDeco - ? this.swatchDeco.createDeco(view) - : Decoration.none; - this.markSet = this.markDeco - ? this.markDeco.createDeco(view) - : Decoration.none; - } - - // Merge the two DecorationSets into one. - private merged(): DecorationSet { - if (this.swatchSet === Decoration.none) return this.markSet; - if (this.markSet === Decoration.none) return this.swatchSet; - return RangeSet.join([this.swatchSet, this.markSet]) as DecorationSet; + private settingsKey(s: ColorPreviewSettings): string { + return `${s.showSwatchInEditor}|${s.colorizeTextInEditor}`; } destroy(): void { /* nothing to clean up */ } } - // Public factory export function createColorPreviewExtension(getSettings: () => ColorPreviewSettings) { return ViewPlugin.fromClass( - class extends ColorPreviewPlugin { - constructor(view: EditorView) { super(view, getSettings); } + class implements PluginValue { + decorations: DecorationSet; + private inner: ColorPreviewPlugin; + + constructor(view: EditorView) { + this.inner = new ColorPreviewPlugin(view, getSettings); + this.decorations = this.inner.decorations; + } + + update(update: ViewUpdate) { + this.inner.update(update); + this.decorations = this.inner.decorations; + } + + destroy() { this.inner.destroy(); } }, - { decorations: (plugin) => plugin.decorations } + { decorations: v => v.decorations } ); } \ No newline at end of file diff --git a/src/styles.css b/src/styles.css index 7e482e5..d2f67be 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,4 +1,3 @@ -/* Color Swatch (used in both editor and reading view) */ .cp-color-swatch { display: inline-block; width: 0.9em; @@ -9,30 +8,37 @@ vertical-align: -0.1em; flex-shrink: 0; cursor: help; - transition: all 0.15s ease-in-out; - - /* Subtle shadow for depth */ - box-shadow: + transition: transform 0.15s ease-in-out, box-shadow 0.15s ease-in-out, border-color 0.15s ease-in-out; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05); } -/* Swatch hover effect */ .cp-color-swatch:hover { transform: scale(1.15); border-color: var(--interactive-accent); - box-shadow: + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.1); } -/* Colored text styling */ -.cp-colored-text { - font-weight: 500; - position: relative; +.cp-color-swatch:focus-visible { + outline: 2px solid var(--interactive-accent); + outline-offset: 2px; +} + +.cp-color-inline { + display: inline-flex; + align-items: center; + cursor: text; + white-space: nowrap; +} + +/* Keep swatch vertically aligned inside the inline widget */ +.cp-color-inline .cp-color-swatch { + vertical-align: -0.1em; } -/* Reading view wrapper */ .cp-color-wrapper { display: inline-flex; align-items: center; @@ -40,57 +46,16 @@ white-space: nowrap; } -/* Prevent text wrapping issues */ .cp-color-wrapper .cp-colored-text, .cp-color-wrapper span:not(.cp-color-swatch) { white-space: nowrap; } -/* Dark mode */ -.theme-dark .cp-color-swatch { - border-color: var(--background-modifier-border-hover); - box-shadow: - inset 0 0 0 1px rgba(255, 255, 255, 0.12), - 0 1px 2px rgba(0, 0, 0, 0.3); +.cp-colored-text { + font-weight: 500; } -.theme-dark .cp-color-swatch:hover { - box-shadow: - inset 0 0 0 1px rgba(255, 255, 255, 0.15), - 0 2px 4px rgba(0, 0, 0, 0.4); -} - -/* Light mode */ -.theme-light .cp-color-swatch { - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.08), - 0 1px 2px rgba(0, 0, 0, 0.05); -} - -.theme-light .cp-color-swatch:hover { - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.12), - 0 2px 4px rgba(0, 0, 0, 0.08); -} - -/* Focus styles for keyboard navigation */ -.cp-color-swatch:focus-visible { - outline: 2px solid var(--interactive-accent); - outline-offset: 2px; -} - -/* Reduced motion support */ -@media (prefers-reduced-motion: reduce) { - .cp-color-swatch { - transition: none; - } - - .cp-color-swatch:hover { - transform: none; - } -} - -/* Ensure very light/dark colors remain visible */ +/* Ensure extreme values don't vanish against the background */ .cp-colored-text[data-color="#ffffff"], .cp-colored-text[data-color="#fff"], .cp-colored-text[data-color="white"], @@ -98,14 +63,44 @@ .cp-colored-text[data-color="#000"], .cp-colored-text[data-color="black"] { color: var(--text-normal) !important; - text-decoration: underline; - text-decoration-style: dotted; - text-decoration-color: var(--text-muted); + text-decoration: underline dotted var(--text-muted); } -/* Ensure swatches in editor don't break line height */ -.cm-line .cp-color-swatch { - vertical-align: -0.1em; +/* Dark mode overrides */ +.theme-dark .cp-color-swatch { + border-color: var(--background-modifier-border-hover); + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.12), + 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.theme-dark .cp-color-swatch:hover { + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.15), + 0 2px 4px rgba(0, 0, 0, 0.4); +} + +/* Light mode overrides */ +.theme-light .cp-color-swatch { + box-shadow: + inset 0 0 0 1px rgba(0, 0, 0, 0.08), + 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.theme-light .cp-color-swatch:hover { + box-shadow: + inset 0 0 0 1px rgba(0, 0, 0, 0.12), + 0 2px 4px rgba(0, 0, 0, 0.08); +} + +@media (prefers-reduced-motion: reduce) { + .cp-color-swatch { + transition: none; + } + + .cp-color-swatch:hover { + transform: none; + } } @media print { @@ -113,7 +108,7 @@ border: 1px solid #000; box-shadow: none; } - + .cp-colored-text { color: inherit !important; font-weight: normal; diff --git a/src/utils/colorParser.ts b/src/utils/colorParser.ts index 57142a9..9c6cada 100644 --- a/src/utils/colorParser.ts +++ b/src/utils/colorParser.ts @@ -5,8 +5,7 @@ export interface ColorMatch { original: string; } -// Regex patterns — compiled once, cloned per-call via new RegExp() so callers -// never share lastIndex state. +// Regex const HEX_PATTERN = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9a-fA-F])/; @@ -20,6 +19,7 @@ const COMBINED_SOURCE = [HEX_PATTERN, RGB_PATTERN, HSL_PATTERN] .map((r) => r.source) .join('|'); +// Parsing function parseHex(hex: string): [number, number, number, number] | null { const h = hex.slice(1); let r: number, g: number, b: number, a = 255; @@ -57,20 +57,12 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] { return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)]; } -/** - * Converts a color string to [r, g, b, a] - * Returns null for unparseable strings. - */ function colorToRgba(colorStr: string): [number, number, number, number] | null { const s = colorStr.trim(); - if (s.startsWith('#')) { - return parseHex(s); - } + if (s.startsWith('#')) return parseHex(s); - const funcMatch = s.match( - /^(rgba?|hsla?)\(\s*([^)]+)\)/i - ); + const funcMatch = s.match(/^(rgba?|hsla?)\(\s*([^)]+)\)/i); if (!funcMatch) return null; const fn = funcMatch[1].toLowerCase(); @@ -87,10 +79,8 @@ function colorToRgba(colorStr: string): [number, number, number, number] | null if (fn === 'hsl' || fn === 'hsla') { const h = parseFloat(parts[0]); - const sv = parts[1].replace('%', ''); - const lv = parts[2].replace('%', ''); - const s2 = parseFloat(sv); - const l2 = parseFloat(lv); + const s2 = parseFloat(parts[1]); + const l2 = parseFloat(parts[2]); const a2 = parts[3] !== undefined ? parseFloat(parts[3]) * 255 : 255; if (isNaN(h) || isNaN(s2) || isNaN(l2)) return null; if (s2 < 0 || s2 > 100 || l2 < 0 || l2 > 100) return null; @@ -101,32 +91,50 @@ function colorToRgba(colorStr: string): [number, number, number, number] | null return null; } -// Validates if a color string is parseable +// Reads --background-primary off document.body, which is set by whatever +// Obsidian theme is active. Falls back to white if unreadable. +function getThemeBackground(): [number, number, number] { + const raw = getComputedStyle(document.body) + .getPropertyValue('--background-primary') + .trim(); + const rgba = colorToRgba(raw); + return rgba ? [rgba[0], rgba[1], rgba[2]] : [255, 255, 255]; +} + +function toLinear(c: number): number { + const s = c / 255; + return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); +} + +function relativeLuminance(r: number, g: number, b: number): number { + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); +} + +function contrastRatio( + [r1, g1, b1]: [number, number, number], + [r2, g2, b2]: [number, number, number] +): number { + const l1 = relativeLuminance(r1, g1, b1); + const l2 = relativeLuminance(r2, g2, b2); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +// Returns true if the color is distinct enough from the current theme +export function hasGoodContrast(colorStr: string): boolean { + const rgba = colorToRgba(colorStr); + if (!rgba) return false; + const bg = getThemeBackground(); + return contrastRatio([rgba[0], rgba[1], rgba[2]], bg) >= 3.0; +} + +// Public helpers export function isValidColor(colorStr: string): boolean { return colorToRgba(colorStr) !== null; } -// Calculates perceived luminance (0–1) -export function calculateLuminance(colorStr: string): number { - const rgba = colorToRgba(colorStr); - if (!rgba) return 0.5; - const [r, g, b] = rgba; - return (0.299 * r + 0.587 * g + 0.114 * b) / 255; -} - -export function isColorTooLight(colorStr: string, threshold = 0.85): boolean { - return calculateLuminance(colorStr) > threshold; -} - -export function isColorTooDark(colorStr: string, threshold = 0.15): boolean { - return calculateLuminance(colorStr) < threshold; -} - -export function hasGoodContrast(colorStr: string): boolean { - return !isColorTooLight(colorStr) && !isColorTooDark(colorStr); -} - -// Finds all valid color matches in a text string. +// Match finder export function findColorsInText(text: string, startOffset = 0): ColorMatch[] { const matches: ColorMatch[] = []; const regex = new RegExp(COMBINED_SOURCE, 'gi');