mirror of
https://github.com/nellowtcs/Obsidian-IroView.git
synced 2026-07-22 06:56:57 +00:00
More fixes
This commit is contained in:
parent
b415f5c8d1
commit
71f0e3eb2b
3 changed files with 188 additions and 186 deletions
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
121
src/styles.css
121
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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue