More fixes

This commit is contained in:
NellowTCS 2026-04-29 16:02:05 -06:00 committed by GitHub
parent b415f5c8d1
commit 71f0e3eb2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 188 additions and 186 deletions

View file

@ -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 }
);
}

View file

@ -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;

View file

@ -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 (01)
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');