Le big cleanup starts

This commit is contained in:
NellowTCS 2026-04-30 20:37:09 +00:00 committed by GitHub
parent 71f0e3eb2b
commit 726bcefe89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 4224 additions and 1363 deletions

View file

@ -1,3 +0,0 @@
node_modules/
main.js

View file

@ -1,23 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off"
}
}

View file

@ -1,86 +1,86 @@
name: Build Obsidian plugin
on:
push:
branches:
- main
workflow_dispatch:
push:
branches:
- main
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Use Node.js 18
uses: actions/setup-node@v3
with:
node-version: "18.x"
- name: Use Node.js 18
uses: actions/setup-node@v3
with:
node-version: "18.x"
- name: Cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm i
- name: Install dependencies
run: npm i
- name: Build plugin
run: npm run build
- name: Build plugin
run: npm run build
- name: Collect dist/main.js and manifest.json and styles.css
id: meta
run: |
set -euo pipefail
mkdir -p build-output
- name: Collect dist/main.js and manifest.json and styles.css
id: meta
run: |
set -euo pipefail
mkdir -p build-output
# copy dist/main.js
if [ -f "dist/main.js" ]; then
cp -v dist/main.js build-output/main.js
else
echo "Error: dist/main.js not found."
ls -la dist || true
exit 1
fi
# copy dist/main.js
if [ -f "dist/main.js" ]; then
cp -v dist/main.js build-output/main.js
else
echo "Error: dist/main.js not found."
ls -la dist || true
exit 1
fi
# copy dist/styles.css
if [ -f "dist/styles.css" ]; then
cp -v dist/styles.css build-output/styles.css
else
echo "Error: dist/styles.css not found."
ls -la dist || true
exit 1
fi
# copy dist/styles.css
if [ -f "dist/styles.css" ]; then
cp -v dist/styles.css build-output/styles.css
else
echo "Error: dist/styles.css not found."
ls -la dist || true
exit 1
fi
# copy manifest.json from repo root
if [ -f "manifest.json" ]; then
cp -v manifest.json build-output/manifest.json
else
echo "Error: manifest.json not found in repository root."
exit 2
fi
# copy manifest.json from repo root
if [ -f "manifest.json" ]; then
cp -v manifest.json build-output/manifest.json
else
echo "Error: manifest.json not found in repository root."
exit 2
fi
# set outputs for artifact naming (requires jq, already in GitHub runners)
PACKAGE_NAME=$(jq -r .name manifest.json)
PACKAGE_VERSION=$(jq -r .version manifest.json)
echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
echo "package_version=${PACKAGE_VERSION}" >> "$GITHUB_OUTPUT"
# set outputs for artifact naming (requires jq, already in GitHub runners)
PACKAGE_NAME=$(jq -r .name manifest.json)
PACKAGE_VERSION=$(jq -r .version manifest.json)
echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
echo "package_version=${PACKAGE_VERSION}" >> "$GITHUB_OUTPUT"
echo "build-output contents:"
ls -la build-output
echo "build-output contents:"
ls -la build-output
- name: Upload artifact (main.js + styles.css+ manifest.json)
uses: actions/upload-artifact@v4
with:
name: ${{ steps.meta.outputs.package_name }}-${{ steps.meta.outputs.package_version }}-${{ github.run_id }}
path: |
build-output/main.js
build-output/styles.css
build-output/manifest.json
- name: Upload artifact (main.js + styles.css+ manifest.json)
uses: actions/upload-artifact@v4
with:
name: ${{ steps.meta.outputs.package_name }}-${{ steps.meta.outputs.package_version }}-${{ github.run_id }}
path: |
build-output/main.js
build-output/styles.css
build-output/manifest.json

View file

@ -1,41 +1,41 @@
name: Release Obsidian plugin
on:
push:
tags:
- "*"
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18.x"
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18.x"
- name: Build plugin
run: |
npm install
npm run build
- name: Build plugin
run: |
npm install
npm run build
- name: Stage release files
run: |
mkdir -p build-output
cp dist/main.js build-output/
cp dist/styles.css build-output/
cp manifest.json build-output/
- name: Stage release files
run: |
mkdir -p build-output
cp dist/main.js build-output/
cp dist/styles.css build-output/
cp manifest.json build-output/
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
gh release create "$tag" \
--title="$tag" \
--draft \
build-output/main.js build-output/manifest.json build-output/styles.css
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
gh release create "$tag" \
--title="$tag" \
--draft \
build-output/main.js build-output/manifest.json build-output/styles.css

View file

@ -1,4 +1,5 @@
# Obsidian-ColorPreview
A color preview for HEX, HSL, RGB values in your notes, similar to VSCode's color preview.
> **WIP**

View file

@ -4,14 +4,13 @@ import builtins from "builtin-modules";
import fs from "fs/promises";
import path from "path";
const banner =
`/*
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const prod = process.argv[2] === "production";
// Helper function to copy files
async function copyFile(src, destDir) {
@ -42,7 +41,7 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins
...builtins,
],
format: "cjs",
target: "es2018",

30
eslint.config.mjs Normal file
View file

@ -0,0 +1,30 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import obsidianmd from "eslint-plugin-obsidianmd";
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
...obsidianmd.configs.recommended,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off",
"no-console": ["error", { "allow": ["warn", "error", "debug"] }],
},
},
{
ignores: ["dist/**/*", "node_modules/**/*", "main.js"]
}
);

4693
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,19 +6,29 @@
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"lint": "eslint src/",
"make-pretty": "prettier --write .",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
"@eslint/js": "^10.0.1",
"@types/node": "^25.6.0",
"@typescript-eslint/eslint-plugin": "8.59.1",
"@typescript-eslint/parser": "8.59.1",
"builtin-modules": "5.1.0",
"esbuild": "0.28.0",
"eslint": "^10.2.1",
"eslint-plugin-obsidianmd": "^0.2.9",
"obsidian": "1.12.3",
"prettier": "^3.8.3",
"tslib": "2.8.1",
"typescript": "6.0.3",
"typescript-eslint": "^8.59.1"
},
"dependencies": {
"@codemirror/view": "^6.38.6"
}
}

View file

@ -7,24 +7,29 @@ import {
WidgetType,
MatchDecorator,
PluginValue,
} from '@codemirror/view';
import { hasGoodContrast } from '../utils/colorParser';
import type { ColorPreviewSettings } from '../types';
} from "@codemirror/view";
import { hasGoodContrast } from "../utils/colorParser";
import type { ColorPreviewSettings } from "../types";
// 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('|');
// Regex
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
// Widget
class ColorWidget extends WidgetType {
constructor(
readonly color: string,
readonly originalText: string,
readonly showSwatch: boolean,
readonly colorizeText: boolean,
) { super(); }
) {
super();
}
eq(other: ColorWidget): boolean {
return (
@ -36,109 +41,87 @@ class ColorWidget extends WidgetType {
}
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}`);
const wrapper = document.createElement("span");
wrapper.className = "cp-color-inline";
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;
const swatch = document.createElement("span");
swatch.className = "cp-color-swatch";
swatch.setCssProps({ "--cp-swatch-color": this.color });
wrapper.appendChild(swatch);
}
const label = document.createElement('span');
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;
if (this.colorizeText && hasGoodContrast(this.color)) {
label.className = "cp-colored-text";
label.setCssProps({ "--cp-text-color": this.color });
}
wrapper.appendChild(label);
return wrapper;
}
ignoreEvent(): boolean { return false; }
ignoreEvent(): boolean {
return false;
}
}
// MatchDecorator factory
// MatchDecorator
function makeDecorator(settings: ColorPreviewSettings): MatchDecorator {
return new MatchDecorator({
regexp: new RegExp(COMBINED_SRC, 'gi'),
decoration: (match) => {
const color = match[0];
return Decoration.replace({
regexp: new RegExp(COMBINED_SRC, "gi"),
decoration: (match) =>
Decoration.replace({
widget: new ColorWidget(
color,
match[0],
match[0],
settings.showSwatchInEditor,
settings.colorizeTextInEditor,
),
});
},
}),
});
}
// ViewPlugin
class ColorPreviewPlugin implements PluginValue {
decorations: DecorationSet;
private decorator: MatchDecorator;
private lastSettingsKey: string;
constructor(
private readonly view: EditorView,
private readonly getSettings: () => ColorPreviewSettings,
) {
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 = this.settingsKey(s);
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 — reuse existing decorations where unchanged
this.decorations = this.decorator.updateDeco(update, this.decorations);
}
}
private settingsKey(s: ColorPreviewSettings): string {
return `${s.showSwatchInEditor}|${s.colorizeTextInEditor}`;
}
destroy(): void { /* nothing to clean up */ }
}
// Public factory
export function createColorPreviewExtension(getSettings: () => ColorPreviewSettings) {
export function createColorPreviewExtension(
getSettings: () => ColorPreviewSettings,
) {
return ViewPlugin.fromClass(
class implements PluginValue {
decorations: DecorationSet;
private inner: ColorPreviewPlugin;
private decorator: MatchDecorator;
private lastKey: string;
constructor(view: EditorView) {
this.inner = new ColorPreviewPlugin(view, getSettings);
this.decorations = this.inner.decorations;
const s = getSettings();
this.lastKey = `${s.showSwatchInEditor}|${s.colorizeTextInEditor}`;
this.decorator = makeDecorator(s);
this.decorations = this.decorator.createDeco(view);
}
update(update: ViewUpdate) {
this.inner.update(update);
this.decorations = this.inner.decorations;
const s = getSettings();
const key = `${s.showSwatchInEditor}|${s.colorizeTextInEditor}`;
if (key !== this.lastKey) {
this.lastKey = key;
this.decorator = makeDecorator(s);
this.decorations = this.decorator.createDeco(update.view);
} else if (update.docChanged || update.viewportChanged) {
this.decorations = this.decorator.updateDeco(
update,
this.decorations,
);
}
}
destroy() { this.inner.destroy(); }
destroy() {
/* nothing to clean up */
}
},
{ decorations: v => v.decorations }
{ decorations: (v) => v.decorations },
);
}
}

View file

@ -1,8 +1,9 @@
import { Plugin, MarkdownPostProcessorContext } from 'obsidian';
import { ColorPreviewSettings, DEFAULT_SETTINGS } from './types';
import { createColorPreviewExtension } from './editor/editorExtension';
import { processReadingView } from './reading/readingViewProcessor';
import { ColorPreviewSettingTab } from './ui/settingsTab';
import { Plugin, MarkdownView, MarkdownPostProcessorContext } from "obsidian";
import { ColorPreviewSettings, DEFAULT_SETTINGS } from "./types";
import { createColorPreviewExtension } from "./editor/editorExtension";
import { processReadingView } from "./reading/readingViewProcessor";
import { ColorPreviewSettingTab } from "./ui/settingsTab";
import type { EditorView } from "@codemirror/view";
export default class ColorPreviewPlugin extends Plugin {
settings: ColorPreviewSettings = { ...DEFAULT_SETTINGS };
@ -10,50 +11,53 @@ export default class ColorPreviewPlugin extends Plugin {
async onload(): Promise<void> {
await this.loadSettings();
// Live preview / source mode
this.registerEditorExtension(
createColorPreviewExtension(() => this.settings)
createColorPreviewExtension(() => this.settings),
);
// 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) => {
if (this.settings.enableInReadingView) {
processReadingView(element, context, this.settings);
}
}
},
);
this.addSettingTab(new ColorPreviewSettingTab(this.app, this));
}
onunload(): void {
// all registered extensions and post-processors are
// cleaned up automatically by the Plugin base class.
}
async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData(),
);
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
// Refresh editor views so the new settings take effect immediately.
// Nudge all open editor views to re-evaluate their decorations.
this.app.workspace.iterateAllLeaves((leaf) => {
const view = leaf.view as any;
if (view?.getViewType?.() === 'markdown' && view?.editor?.cm) {
view.editor.cm.dispatch({});
const view = leaf.view;
if (view instanceof MarkdownView) {
// editor.cm is not in the public API types but is stable
const cm = (view.editor as unknown as { cm?: EditorView }).cm;
if (cm) cm.dispatch({});
}
});
// Reading view: re-opening the note re-runs the post-processor naturally.
// For an immediate refresh without closing the note, re-render the preview.
// Re-render reading view panes so post-processors run again with new settings.
this.app.workspace.iterateAllLeaves((leaf) => {
const view = leaf.view as any;
if (view?.getViewType?.() === 'markdown' && view?.previewMode) {
view.previewMode.rerender(true);
const view = leaf.view;
if (view instanceof MarkdownView) {
// previewMode.rerender is not in public types
const preview = (
view as unknown as {
previewMode?: { rerender: (full: boolean) => void };
}
).previewMode;
if (preview) preview.rerender(true);
}
});
}

View file

@ -1,9 +1,8 @@
import { MarkdownRenderChild } from 'obsidian';
import { findColorsInText, hasGoodContrast } from '../utils/colorParser';
import type { ColorPreviewSettings } from '../types';
import type { MarkdownPostProcessorContext } from 'obsidian';
import { MarkdownRenderChild } from "obsidian";
import { findColorsInText, hasGoodContrast } from "../utils/colorParser";
import type { ColorPreviewSettings } from "../types";
import type { MarkdownPostProcessorContext } from "obsidian";
// Wraps a single paragraph element (<p>, <li>, etc.) that contains color strings
class ColorSpanChild extends MarkdownRenderChild {
constructor(
containerEl: HTMLElement,
@ -13,32 +12,36 @@ class ColorSpanChild extends MarkdownRenderChild {
}
onload(): void {
this.processElement(this.containerEl);
}
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();
}
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);
const textNodes = this.collectTextNodes(this.containerEl);
for (const node of textNodes) {
this.processTextNode(node);
}
}
onunload(): void {
this.containerEl
.querySelectorAll(".cp-color-wrapper")
.forEach((wrapper) => {
wrapper.replaceWith(
document.createTextNode(wrapper.textContent ?? ""),
);
});
this.containerEl.normalize();
}
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;
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;
},
});
@ -48,7 +51,7 @@ class ColorSpanChild extends MarkdownRenderChild {
}
private processTextNode(node: Text): void {
const text = node.nodeValue ?? '';
const text = node.nodeValue ?? "";
const matches = findColorsInText(text);
if (matches.length === 0) return;
@ -57,7 +60,9 @@ class ColorSpanChild extends MarkdownRenderChild {
for (const match of matches) {
if (match.from > cursor) {
fragment.appendChild(document.createTextNode(text.slice(cursor, match.from)));
fragment.appendChild(
document.createTextNode(text.slice(cursor, match.from)),
);
}
fragment.appendChild(this.createColorElement(match.color));
cursor = match.to;
@ -70,23 +75,23 @@ class ColorSpanChild extends MarkdownRenderChild {
}
private createColorElement(color: string): HTMLElement {
const wrapper = document.createElement('span');
wrapper.className = 'cp-color-wrapper';
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}`);
const swatch = document.createElement("span");
swatch.className = "cp-color-swatch";
swatch.setAttribute("aria-label", `Color: ${color}`);
swatch.setCssProps({ "--cp-swatch-color": color });
wrapper.appendChild(swatch);
}
const label = document.createElement('span');
const label = document.createElement("span");
label.textContent = color;
if (this.settings.colorizeTextInEditor && hasGoodContrast(color)) {
label.className = 'cp-colored-text';
label.style.color = color;
label.className = "cp-colored-text";
label.setCssProps({ "--cp-text-color": color });
}
wrapper.appendChild(label);
@ -94,15 +99,10 @@ class ColorSpanChild extends MarkdownRenderChild {
}
}
// 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));
}

View file

@ -1,116 +1,108 @@
.cp-color-swatch {
display: inline-block;
width: 0.9em;
height: 0.9em;
border-radius: 3px;
margin-right: 0.35em;
border: 1px solid var(--background-modifier-border);
vertical-align: -0.1em;
flex-shrink: 0;
cursor: help;
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);
display: inline-block;
width: 0.9em;
height: 0.9em;
border-radius: 3px;
margin-right: 0.35em;
border: 1px solid var(--background-modifier-border);
vertical-align: -0.1em;
flex-shrink: 0;
cursor: help;
background-color: var(--cp-swatch-color, transparent);
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);
}
.cp-color-swatch:hover {
transform: scale(1.15);
border-color: var(--interactive-accent);
box-shadow:
inset 0 0 0 1px rgba(0, 0, 0, 0.1),
0 2px 4px rgba(0, 0, 0, 0.1);
transform: scale(1.15);
border-color: var(--interactive-accent);
box-shadow:
inset 0 0 0 1px rgba(0, 0, 0, 0.1),
0 2px 4px rgba(0, 0, 0, 0.1);
}
.cp-color-swatch:focus-visible {
outline: 2px solid var(--interactive-accent);
outline-offset: 2px;
outline: 2px solid var(--interactive-accent);
outline-offset: 2px;
}
.cp-colored-text {
font-weight: 500;
color: var(--cp-text-color, inherit);
}
.cp-color-inline {
display: inline-flex;
align-items: center;
cursor: text;
white-space: nowrap;
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;
vertical-align: -0.1em;
}
.cp-color-wrapper {
display: inline-flex;
align-items: center;
gap: 0.25em;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 0.25em;
white-space: nowrap;
}
.cp-color-wrapper .cp-colored-text,
.cp-color-wrapper span:not(.cp-color-swatch) {
white-space: nowrap;
white-space: nowrap;
}
.cp-colored-text {
font-weight: 500;
}
/* 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"],
.cp-colored-text[data-color="#000000"],
.cp-colored-text[data-color="#000"],
.cp-colored-text[data-color="black"] {
color: var(--text-normal) !important;
text-decoration: underline dotted var(--text-muted);
}
/* 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);
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);
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);
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);
transform: scale(1.15);
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 {
transition: none;
}
.cp-color-swatch:hover {
transform: none;
}
.cp-color-swatch:hover {
transform: none;
}
}
@media print {
.cp-color-swatch {
border: 1px solid #000;
box-shadow: none;
}
.cp-color-swatch {
border: 1px solid #000;
box-shadow: none;
}
.cp-colored-text {
color: inherit !important;
font-weight: normal;
}
}
.cp-colored-text {
color: inherit !important;
font-weight: normal;
}
}

View file

@ -6,10 +6,10 @@
export interface ColorPreviewSettings {
/** Show color swatch in editor */
showSwatchInEditor: boolean;
/** Colorize the text itself in editor */
colorizeTextInEditor: boolean;
/** Enable color previews in reading view */
enableInReadingView: boolean;
}
@ -26,14 +26,14 @@ export const DEFAULT_SETTINGS: ColorPreviewSettings = {
/**
* Constants
*/
export const PLUGIN_NAME = 'Color Preview';
export const PLUGIN_ID = 'color-preview';
export const PLUGIN_NAME = "Color Preview";
export const PLUGIN_ID = "color-preview";
/**
* CSS class names
*/
export const CSS_CLASSES = {
swatch: 'cp-color-swatch',
coloredText: 'cp-colored-text',
wrapper: 'cp-color-wrapper',
} as const;
swatch: "cp-color-swatch",
coloredText: "cp-colored-text",
wrapper: "cp-color-wrapper",
} as const;

View file

@ -1,15 +1,10 @@
// src/ui/settingsTab.ts
import { App, PluginSettingTab, Setting } from "obsidian";
import type ColorPreviewPlugin from "../main";
import { App, PluginSettingTab, Setting } from 'obsidian';
import type ColorPreviewPlugin from '../main';
/**
* Settings tab for the Color Preview plugin
*/
export class ColorPreviewSettingTab extends PluginSettingTab {
constructor(
app: App,
private readonly plugin: ColorPreviewPlugin
private readonly plugin: ColorPreviewPlugin,
) {
super(app, plugin);
}
@ -18,34 +13,25 @@ export class ColorPreviewSettingTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
// Header
containerEl.createEl('h2', { text: 'Color Preview Settings' });
new Setting(containerEl).setName("Configuration").setHeading();
// Description
containerEl.createEl('p', {
text: 'Configure how color values are displayed in your notes.',
cls: 'setting-item-description',
});
// Show color swatch setting
new Setting(containerEl)
.setName('Show color swatch')
.setDesc('Display a small colored square before color values')
.setName("Show color swatch")
.setDesc("Display a small colored square before color values")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.showSwatchInEditor)
.onChange(async (value) => {
this.plugin.settings.showSwatchInEditor = value;
await this.plugin.saveSettings();
})
}),
);
// Colorize text setting
new Setting(containerEl)
.setName('Colorize text')
.setName("Colorize text")
.setDesc(
'Apply the color to the text itself (e.g., #ff0000 appears in red). ' +
'Very light or dark colors will not be colorized for readability.'
"Apply the color to the text itself (e.g., #ff0000 appears in red). " +
"Colors with low contrast against the current theme background will not be colorized.",
)
.addToggle((toggle) =>
toggle
@ -53,32 +39,27 @@ export class ColorPreviewSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.colorizeTextInEditor = value;
await this.plugin.saveSettings();
})
}),
);
// Enable in reading view setting
new Setting(containerEl)
.setName('Enable in reading view')
.setDesc('Show color previews when viewing rendered markdown')
.setName("Enable in reading view")
.setDesc("Show color previews when viewing rendered markdown")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.enableInReadingView)
.onChange(async (value) => {
this.plugin.settings.enableInReadingView = value;
await this.plugin.saveSettings();
})
}),
);
// Supported formats info
containerEl.createDiv({ cls: 'setting-item-description' }, (div) => {
div.createEl('h3', { text: 'Supported Color Formats', cls: 'u-font-weight-bold' });
div.createEl('ul', {}, (ul) => {
ul.createEl('li', { text: 'HEX: #RGB, #RRGGBB, #RGBA, #RRGGBBAA' });
ul.createEl('li', { text: 'RGB: rgb(255, 0, 0)' });
ul.createEl('li', { text: 'RGBA: rgba(255, 0, 0, 0.5)' });
ul.createEl('li', { text: 'HSL: hsl(120, 100%, 50%)' });
ul.createEl('li', { text: 'HSLA: hsla(120, 100%, 50%, 0.5)' });
});
});
new Setting(containerEl)
.setName("Supported color formats")
.setHeading();
new Setting(containerEl).setDesc(
"HEX: #RGB, #RRGGBB, #RGBA, #RRGGBBAA · RGB/RGBA: rgb(255, 0, 0) · HSL/HSLA: hsl(120, 100%, 50%)",
);
}
}
}

View file

@ -17,12 +17,15 @@ const HSL_PATTERN =
const COMBINED_SOURCE = [HEX_PATTERN, RGB_PATTERN, HSL_PATTERN]
.map((r) => r.source)
.join('|');
.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;
let r: number,
g: number,
b: number,
a = 255;
if (h.length === 3) {
r = parseInt(h[0] + h[0], 16);
g = parseInt(h[1] + h[1], 16);
@ -54,21 +57,28 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
const a = s * Math.min(l, 1 - l);
const f = (n: number) =>
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
return [
Math.round(f(0) * 255),
Math.round(f(8) * 255),
Math.round(f(4) * 255),
];
}
function colorToRgba(colorStr: string): [number, number, number, number] | null {
function colorToRgba(
colorStr: string,
): [number, number, number, number] | null {
const s = colorStr.trim();
if (!s) return null;
if (s.startsWith('#')) return parseHex(s);
if (s.startsWith("#")) return parseHex(s);
const funcMatch = s.match(/^(rgba?|hsla?)\(\s*([^)]+)\)/i);
if (!funcMatch) return null;
const fn = funcMatch[1].toLowerCase();
const parts = funcMatch[2].split(',').map((p) => p.trim());
const parts = funcMatch[2].split(",").map((p) => p.trim());
if (fn === 'rgb' || fn === 'rgba') {
if (fn === "rgb" || fn === "rgba") {
const r = parseInt(parts[0]);
const g = parseInt(parts[1]);
const b = parseInt(parts[2]);
@ -77,7 +87,7 @@ function colorToRgba(colorStr: string): [number, number, number, number] | null
return [r, g, b, a];
}
if (fn === 'hsl' || fn === 'hsla') {
if (fn === "hsl" || fn === "hsla") {
const h = parseFloat(parts[0]);
const s2 = parseFloat(parts[1]);
const l2 = parseFloat(parts[2]);
@ -91,16 +101,43 @@ function colorToRgba(colorStr: string): [number, number, number, number] | null
return null;
}
// 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];
// Theme background
let cachedBg: [number, number, number] | null = null;
let cacheScheduled = false;
function invalidateBgCache() {
cachedBg = null;
cacheScheduled = false;
}
function getThemeBackground(): [number, number, number] {
if (cachedBg) return cachedBg;
const raw = getComputedStyle(document.body)
.getPropertyValue("--background-primary")
.trim();
const parsed = colorToRgba(raw);
if (parsed) {
cachedBg = [parsed[0], parsed[1], parsed[2]];
} else {
// --background-primary uses a format we can't parse (oklch, color-mix, etc.)
// Fall back to theme class detection.
const isDark = document.body.classList.contains("theme-dark");
cachedBg = isDark ? [30, 30, 30] : [255, 255, 255];
}
// Invalidate once per frame so theme switches are picked up promptly
if (!cacheScheduled) {
cacheScheduled = true;
requestAnimationFrame(invalidateBgCache);
}
return cachedBg;
}
// WCAG contrast
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);
@ -112,7 +149,7 @@ function relativeLuminance(r: number, g: number, b: number): number {
function contrastRatio(
[r1, g1, b1]: [number, number, number],
[r2, g2, b2]: [number, number, number]
[r2, g2, b2]: [number, number, number],
): number {
const l1 = relativeLuminance(r1, g1, b1);
const l2 = relativeLuminance(r2, g2, b2);
@ -134,10 +171,9 @@ export function isValidColor(colorStr: string): boolean {
return colorToRgba(colorStr) !== null;
}
// Match finder
export function findColorsInText(text: string, startOffset = 0): ColorMatch[] {
const matches: ColorMatch[] = [];
const regex = new RegExp(COMBINED_SOURCE, 'gi');
const regex = new RegExp(COMBINED_SOURCE, "gi");
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {

View file

@ -1,25 +1,17 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"allowSyntheticDefaultImports": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
"compilerOptions": {
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "bundler",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"allowSyntheticDefaultImports": true,
"lib": ["DOM", "ES5", "ES6", "ES7"]
},
"include": ["**/*.ts"]
}