Using existing APIs better

This commit is contained in:
NellowTCS 2026-04-29 15:49:25 -06:00 committed by GitHub
parent e4c131c6f1
commit b415f5c8d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 215 additions and 257 deletions

View file

@ -5,141 +5,141 @@ import {
Decoration,
DecorationSet,
WidgetType,
MatchDecorator,
PluginValue,
} from '@codemirror/view';
import { RangeSetBuilder } from '@codemirror/state';
import { findColorsInText, hasGoodContrast, ColorMatch } from '../utils/colorParser';
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).
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('|');
// Swatch widget
class ColorSwatchWidget extends WidgetType {
constructor(private readonly color: string) {
super();
}
constructor(readonly color: string) { super(); }
toDOM(): HTMLElement {
const swatch = document.createElement('span');
swatch.className = 'cp-color-swatch';
swatch.style.backgroundColor = this.color;
swatch.setAttribute('data-color', this.color);
swatch.setAttribute('aria-label', `Color preview: ${this.color}`);
return swatch;
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: ColorSwatchWidget): boolean {
return this.color === other.color;
}
ignoreEvent(): boolean {
return true;
}
eq(other: ColorSwatchWidget): boolean { return this.color === other.color; }
ignoreEvent(): boolean { return true; }
}
// Decoration builder
// collect all (from, to, decoration) triples, sort them once, then feed them to the builder
function buildDecorations(
matches: ColorMatch[],
settings: ColorPreviewSettings
): DecorationSet {
type Entry = { from: number; to: number; decoration: Decoration };
const entries: Entry[] = [];
for (const match of matches) {
if (settings.showSwatchInEditor) {
// Point decoration: from === to === match.from
entries.push({
from: match.from,
to: match.from,
decoration: Decoration.widget({
widget: new ColorSwatchWidget(match.color),
side: -1, // render before the character at `from`
}),
});
}
if (settings.colorizeTextInEditor && hasGoodContrast(match.color)) {
entries.push({
from: match.from,
to: match.to,
decoration: Decoration.mark({
class: 'cp-colored-text',
attributes: {
style: `color: ${match.color} !important;`,
'data-color': match.color,
},
}),
});
}
}
// Sort: primary key = from ascending; secondary = to ascending so that
// the zero-length point decoration (to === from) comes before the mark
// (to > from) when they share the same from.
entries.sort((a, b) => a.from - b.from || a.to - b.to);
const builder = new RangeSetBuilder<Decoration>();
for (const { from, to, decoration } of entries) {
builder.add(from, to, decoration);
}
return builder.finish();
// MatchDecorator factories
function makeSwatchDecorator(): 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 },
});
},
});
}
// ViewPlugin
function collectVisibleMatches(view: EditorView): ColorMatch[] {
const matches: ColorMatch[] = [];
for (const { from, to } of view.visibleRanges) {
const text = view.state.doc.sliceString(from, to);
matches.push(...findColorsInText(text, from));
}
return matches;
}
function settingsKey(s: ColorPreviewSettings): string {
return `${s.showSwatchInEditor}|${s.colorizeTextInEditor}`;
}
class ColorPreviewViewPlugin {
class ColorPreviewPlugin implements PluginValue {
decorations: DecorationSet;
private lastSettingsKey: string;
private swatchDeco: MatchDecorator | null = null;
private markDeco: MatchDecorator | null = null;
private swatchSet: DecorationSet = Decoration.none;
private markSet: DecorationSet = Decoration.none;
private lastSettingsKey = '';
constructor(
private readonly view: EditorView,
private readonly getSettings: () => ColorPreviewSettings
private readonly getSettings: () => ColorPreviewSettings,
) {
this.lastSettingsKey = settingsKey(this.getSettings());
this.decorations = this.rebuild();
this.initialize(view);
this.decorations = this.merged();
}
update(update: ViewUpdate): void {
const currentKey = settingsKey(this.getSettings());
const settingsChanged = currentKey !== this.lastSettingsKey;
const s = this.getSettings();
const key = `${s.showSwatchInEditor}|${s.colorizeTextInEditor}`;
const settingsChanged = key !== this.lastSettingsKey;
if (update.docChanged || update.viewportChanged || settingsChanged || update.geometryChanged) {
this.lastSettingsKey = currentKey;
this.decorations = this.rebuild();
if (settingsChanged) {
// Settings changed — recreate decorators and rebuild from scratch.
this.initialize(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
}
this.decorations = this.merged();
}
private rebuild(): DecorationSet {
try {
const matches = collectVisibleMatches(this.view);
return buildDecorations(matches, this.getSettings());
} catch (err) {
console.error('ColorPreview: Failed to build decorations', err);
return Decoration.none;
}
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;
}
destroy(): void { /* nothing to clean up */ }
}
// Public factory
export function createColorPreviewExtension(getSettings: () => ColorPreviewSettings) {
return ViewPlugin.fromClass(
class extends ColorPreviewViewPlugin {
constructor(view: EditorView) {
super(view, getSettings);
}
class extends ColorPreviewPlugin {
constructor(view: EditorView) { super(view, getSettings); }
},
{
decorations: (plugin) => plugin.decorations,
}
{ decorations: (plugin) => plugin.decorations }
);
}

View file

@ -1,110 +1,59 @@
import { Plugin, MarkdownPostProcessorContext } from 'obsidian';
import { ColorPreviewSettings, DEFAULT_SETTINGS } from './types';
import { createColorPreviewExtension } from './editor/editorExtension';
import { processReadingView, clearReadingView } from './reading/readingViewProcessor';
import { processReadingView } from './reading/readingViewProcessor';
import { ColorPreviewSettingTab } from './ui/settingsTab';
export default class ColorPreviewPlugin extends Plugin {
settings: ColorPreviewSettings = { ...DEFAULT_SETTINGS };
/**
* Plugin initialization
*/
async onload(): Promise<void> {
console.log('Loading Color Preview plugin');
await this.loadSettings();
// Register editor extension for live preview
// Pass the settings object directly - it will be read reactively
// Live preview / source mode
this.registerEditorExtension(
createColorPreviewExtension(() => this.settings)
);
// Register markdown post-processor for reading view
// Obsidian calls this once per block-level element. We hand each block to
// a MarkdownRenderChild so Obsidian manages the mount/unmount lifecycle.
this.registerMarkdownPostProcessor(
(element: HTMLElement, _context: MarkdownPostProcessorContext) => {
(element: HTMLElement, context: MarkdownPostProcessorContext) => {
if (this.settings.enableInReadingView) {
processReadingView(element, this.settings);
processReadingView(element, context, this.settings);
}
}
);
// Add settings tab
this.addSettingTab(new ColorPreviewSettingTab(this.app, this));
}
/**
* Plugin cleanup
*/
onunload(): void {
console.log('Unloading Color Preview plugin');
// all registered extensions and post-processors are
// cleaned up automatically by the Plugin base class.
}
/**
* Load plugin settings
*/
async loadSettings(): Promise<void> {
const data = await this.loadData();
this.settings = Object.assign({}, DEFAULT_SETTINGS, data);
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
/**
* Save plugin settings and refresh views
*/
async saveSettings(): Promise<void> {
console.log('ColorPreview: Saving settings', this.settings);
await this.saveData(this.settings);
// Force refresh of all editor views
this.refreshEditorViews();
// Refresh reading views with a small delay
setTimeout(() => {
this.refreshReadingViews();
}, 100);
}
/**
* Force refresh of all CodeMirror editor views
*/
private refreshEditorViews(): void {
// Refresh editor views so the new settings take effect immediately.
this.app.workspace.iterateAllLeaves((leaf) => {
const view = leaf.view;
if (view.getViewType() === 'markdown') {
const markdownView = view as any;
if (markdownView.editor?.cm) {
const cm = markdownView.editor.cm;
// Force a full viewport update
cm.requestMeasure();
// Dispatch an empty transaction to trigger decoration rebuild
cm.dispatch({
effects: [],
});
}
const view = leaf.view as any;
if (view?.getViewType?.() === 'markdown' && view?.editor?.cm) {
view.editor.cm.dispatch({});
}
});
}
/**
* Refresh all reading view elements
*/
private refreshReadingViews(): void {
const readingViews = document.querySelectorAll('.markdown-preview-view');
readingViews.forEach((element) => {
const htmlElement = element as HTMLElement;
// Clear existing previews
clearReadingView(htmlElement);
// Re-process if enabled
if (this.settings.enableInReadingView) {
processReadingView(htmlElement, this.settings);
// Reading view: re-opening the note re-runs the post-processor naturally.
// For an immediate refresh without closing the note, re-render the preview.
this.app.workspace.iterateAllLeaves((leaf) => {
const view = leaf.view as any;
if (view?.getViewType?.() === 'markdown' && view?.previewMode) {
view.previewMode.rerender(true);
}
});
}

View file

@ -1,99 +1,108 @@
import { MarkdownRenderChild } from 'obsidian';
import { findColorsInText, hasGoodContrast } from '../utils/colorParser';
import type { ColorPreviewSettings } from '../types';
import type { MarkdownPostProcessorContext } from 'obsidian';
// Helpers
function shouldProcessNode(node: Node): boolean {
if (!node.nodeValue?.trim()) return false;
// Skip code/pre blocks
if ((node as Text).parentElement?.closest('code, pre')) return false;
// Skip already-processed wrappers
if ((node as Text).parentElement?.classList.contains('cp-color-wrapper')) return false;
return true;
}
function cleanupExistingPreviews(root: HTMLElement): void {
// Replace each wrapper with its plain-text content, then normalize.
root.querySelectorAll('.cp-color-wrapper').forEach((wrapper) => {
const text = document.createTextNode(wrapper.textContent ?? '');
wrapper.parentNode?.replaceChild(text, wrapper);
});
root.normalize();
}
function createColorElement(colorStr: string, settings: ColorPreviewSettings): HTMLElement {
const wrapper = document.createElement('span');
wrapper.className = 'cp-color-wrapper';
if (settings.showSwatchInEditor) {
const swatch = document.createElement('span');
swatch.className = 'cp-color-swatch';
swatch.style.backgroundColor = colorStr;
swatch.setAttribute('aria-label', `Color: ${colorStr}`);
wrapper.appendChild(swatch);
// Wraps a single paragraph element (<p>, <li>, etc.) that contains color strings
class ColorSpanChild extends MarkdownRenderChild {
constructor(
containerEl: HTMLElement,
private readonly settings: ColorPreviewSettings,
) {
super(containerEl);
}
const label = document.createElement('span');
label.textContent = colorStr;
if (settings.colorizeTextInEditor && hasGoodContrast(colorStr)) {
label.className = 'cp-colored-text';
label.style.color = colorStr;
onload(): void {
this.processElement(this.containerEl);
}
wrapper.appendChild(label);
return wrapper;
}
function processTextNode(node: Text, settings: ColorPreviewSettings): void {
const text = node.nodeValue ?? '';
const matches = findColorsInText(text);
if (matches.length === 0) return;
const fragment = document.createDocumentFragment();
let cursor = 0;
for (const match of matches) {
if (match.from > cursor) {
fragment.appendChild(document.createTextNode(text.slice(cursor, match.from)));
}
fragment.appendChild(createColorElement(match.color, settings));
cursor = match.to;
onunload(): void {
// replace every .cp-color-wrapper with its text content to restore
this.containerEl.querySelectorAll('.cp-color-wrapper').forEach((wrapper) => {
wrapper.replaceWith(document.createTextNode(wrapper.textContent ?? ''));
});
this.containerEl.normalize();
}
if (cursor < text.length) {
fragment.appendChild(document.createTextNode(text.slice(cursor)));
}
node.parentNode?.replaceChild(fragment, node);
}
// Public API
export function processReadingView(root: HTMLElement, settings: ColorPreviewSettings): void {
cleanupExistingPreviews(root);
// Collect all text nodes before touching the DOM, mutating the tree
// while a TreeWalker is active causes nodes to be skipped.
const textNodes: Text[] = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode: (node) =>
shouldProcessNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
});
let n: Node | null;
while ((n = walker.nextNode())) {
textNodes.push(n as Text);
}
// Now mutate
for (const node of textNodes) {
try {
processTextNode(node, settings);
} catch (err) {
console.error('ColorPreview: Error processing text node', err);
private processElement(root: HTMLElement): void {
// Mutating the tree while a TreeWalker is active causes nodes to be skipped or revisited.
const textNodes = this.collectTextNodes(root);
for (const node of textNodes) {
this.processTextNode(node);
}
}
private collectTextNodes(root: HTMLElement): Text[] {
const nodes: Text[] = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
if (!node.nodeValue?.trim()) return NodeFilter.FILTER_REJECT;
if ((node as Text).parentElement?.closest('code, pre')) return NodeFilter.FILTER_REJECT;
if ((node as Text).parentElement?.classList.contains('cp-color-wrapper')) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
},
});
let n: Node | null;
while ((n = walker.nextNode())) nodes.push(n as Text);
return nodes;
}
private processTextNode(node: Text): void {
const text = node.nodeValue ?? '';
const matches = findColorsInText(text);
if (matches.length === 0) return;
const fragment = document.createDocumentFragment();
let cursor = 0;
for (const match of matches) {
if (match.from > cursor) {
fragment.appendChild(document.createTextNode(text.slice(cursor, match.from)));
}
fragment.appendChild(this.createColorElement(match.color));
cursor = match.to;
}
if (cursor < text.length) {
fragment.appendChild(document.createTextNode(text.slice(cursor)));
}
node.parentNode?.replaceChild(fragment, node);
}
private createColorElement(color: string): HTMLElement {
const wrapper = document.createElement('span');
wrapper.className = 'cp-color-wrapper';
if (this.settings.showSwatchInEditor) {
const swatch = document.createElement('span');
swatch.className = 'cp-color-swatch';
swatch.style.backgroundColor = color;
swatch.setAttribute('aria-label', `Color: ${color}`);
wrapper.appendChild(swatch);
}
const label = document.createElement('span');
label.textContent = color;
if (this.settings.colorizeTextInEditor && hasGoodContrast(color)) {
label.className = 'cp-colored-text';
label.style.color = color;
}
wrapper.appendChild(label);
return wrapper;
}
}
export function clearReadingView(root: HTMLElement): void {
cleanupExistingPreviews(root);
// Call processReadingView from registerMarkdownPostProcessor.
// Pass the context so Obsidian can manage the child's lifecycle automatically.
export function processReadingView(
element: HTMLElement,
context: MarkdownPostProcessorContext,
settings: ColorPreviewSettings,
): void {
// registerMarkdownPostProcessor receives one block-level element at a time
// (a <p>, <ul>, <h1>, etc.). We register one ColorSpanChild per block.
// Obsidian calls onload() immediately and onunload() when it's removed.
context.addChild(new ColorSpanChild(element, settings));
}