diff --git a/src/editor/editorExtension.ts b/src/editor/editorExtension.ts index 18a0fcb..2133e33 100644 --- a/src/editor/editorExtension.ts +++ b/src/editor/editorExtension.ts @@ -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(); - 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 } ); } \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 73e6922..ee9c7cc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { - 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 { - 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 { - 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); } }); } diff --git a/src/reading/readingViewProcessor.ts b/src/reading/readingViewProcessor.ts index 2372476..926d1ae 100644 --- a/src/reading/readingViewProcessor.ts +++ b/src/reading/readingViewProcessor.ts @@ -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 (

,

  • , 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

    ,

      ,

      , etc.). We register one ColorSpanChild per block. + // Obsidian calls onload() immediately and onunload() when it's removed. + context.addChild(new ColorSpanChild(element, settings)); }