matheuszarkov_gradient-text/main.js
2026-06-12 08:18:41 -03:00

618 lines
22 KiB
JavaScript

const { Plugin, PluginSettingTab, Setting, Modal } = require('obsidian');
// ─── Color utilities ──────────────────────────────────────────────────────────
function hexToRgb(hex) {
hex = hex.replace(/^#/, '');
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
const n = parseInt(hex, 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function rgbToHex(r, g, b) {
return '#' + [r, g, b]
.map(v => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0'))
.join('');
}
function rgbToHsv(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
let h = 0;
if (d !== 0) {
switch (max) {
case r: h = ((g - b) / d % 6) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
if (h < 0) h += 1;
}
return { h: h * 360, s: max === 0 ? 0 : (d / max) * 100, v: max * 100 };
}
function hsvToRgb(h, s, v) {
h /= 360; s /= 100; v /= 100;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s);
let r, g, b;
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h;
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
}
function hslToHex(h, s, l) {
s /= 100; l /= 100;
const k = n => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = n => Math.round((l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))) * 255);
return rgbToHex(f(0), f(8), f(4));
}
function buildCustomGradient(colors, angle = 45) {
const c1 = colors.color1 || '#ffffff';
const c3 = colors.color3 || '#000000';
if (colors.color2) {
return `linear-gradient(${angle}deg, ${c1} 0%, ${colors.color2} 50%, ${c3} 100%)`;
}
return `linear-gradient(${angle}deg, ${c1} 0%, ${c3} 100%)`;
}
// ─── Color picker modal ───────────────────────────────────────────────────────
class ColorPickerModal extends Modal {
constructor(app, currentColor, onSave) {
super(app);
this.onSave = onSave;
this.mode = 'HEX';
const hex = /^#[0-9a-fA-F]{6}$/.test(currentColor) ? currentColor : '#ff0000';
const { r, g, b } = hexToRgb(hex);
const hsv = rgbToHsv(r, g, b);
this.h = hsv.h;
this.s = hsv.s;
this.v = hsv.v;
this._dragging = null;
this._onMouseMove = null;
this._onMouseUp = null;
}
getCurrentHex() {
const { r, g, b } = hsvToRgb(this.h, this.s, this.v);
return rgbToHex(r, g, b);
}
onOpen() {
const { contentEl, modalEl } = this;
contentEl.addClass('gh-color-picker');
modalEl.addClass('gh-color-picker-modal');
this.svCanvas = contentEl.createEl('canvas', { cls: 'gh-sv-canvas' });
this.svCanvas.width = 260;
this.svCanvas.height = 175;
this.svCtx = this.svCanvas.getContext('2d');
this.hueCanvas = contentEl.createEl('canvas', { cls: 'gh-hue-canvas' });
this.hueCanvas.width = 260;
this.hueCanvas.height = 18;
this.hueCtx = this.hueCanvas.getContext('2d');
const row1 = contentEl.createDiv('gh-cp-row1');
this.previewEl = row1.createDiv('gh-cp-preview');
this.inputEl = row1.createEl('input', { cls: 'gh-cp-input', type: 'text' });
const modeGroup1 = row1.createDiv('gh-cp-mode-group');
this.modeBtns = {};
['HEX', 'RGB'].forEach(m => {
const btn = modeGroup1.createEl('button', { text: m, cls: 'gh-cp-mode-btn' });
if (m === this.mode) btn.addClass('active');
btn.addEventListener('click', () => this.switchMode(m));
this.modeBtns[m] = btn;
});
const row2 = contentEl.createDiv('gh-cp-row2');
const modeGroup2 = row2.createDiv('gh-cp-mode-group');
const hslBtn = modeGroup2.createEl('button', { text: 'HSL', cls: 'gh-cp-mode-btn' });
hslBtn.addEventListener('click', () => this.switchMode('HSL'));
this.modeBtns['HSL'] = hslBtn;
const actions = row2.createDiv('gh-cp-actions');
const saveBtn = actions.createEl('button', { text: 'Save', cls: 'gh-cp-save mod-cta' });
saveBtn.addEventListener('click', () => { this.onSave(this.getCurrentHex()); this.close(); });
const cancelBtn = actions.createEl('button', { text: 'Cancel', cls: 'gh-cp-cancel' });
cancelBtn.addEventListener('click', () => this.close());
this.renderAll();
this.setupEvents();
}
switchMode(m) {
this.mode = m;
Object.values(this.modeBtns).forEach(b => b.removeClass('active'));
this.modeBtns[m].addClass('active');
this.updateInput();
}
renderAll() {
this.drawSV();
this.drawHue();
this.updatePreview();
this.updateInput();
}
drawSV() {
const ctx = this.svCtx;
const w = this.svCanvas.width, h = this.svCanvas.height;
const { r, g, b } = hsvToRgb(this.h, 100, 100);
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(0, 0, w, h);
const wGrad = ctx.createLinearGradient(0, 0, w, 0);
wGrad.addColorStop(0, 'rgba(255,255,255,1)');
wGrad.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = wGrad;
ctx.fillRect(0, 0, w, h);
const bGrad = ctx.createLinearGradient(0, 0, 0, h);
bGrad.addColorStop(0, 'rgba(0,0,0,0)');
bGrad.addColorStop(1, 'rgba(0,0,0,1)');
ctx.fillStyle = bGrad;
ctx.fillRect(0, 0, w, h);
const cx = (this.s / 100) * w;
const cy = (1 - this.v / 100) * h;
ctx.beginPath();
ctx.arc(cx, cy, 7, 0, Math.PI * 2);
ctx.strokeStyle = 'white';
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy, 8.5, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
ctx.lineWidth = 1;
ctx.stroke();
}
drawHue() {
const ctx = this.hueCtx;
const w = this.hueCanvas.width, h = this.hueCanvas.height;
const grad = ctx.createLinearGradient(0, 0, w, 0);
for (let i = 0; i <= 12; i++) grad.addColorStop(i / 12, `hsl(${i * 30},100%,50%)`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
const cx = (this.h / 360) * w;
const r = h / 2 - 1;
ctx.beginPath();
ctx.arc(cx, h / 2, r, 0, Math.PI * 2);
ctx.strokeStyle = 'white';
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.arc(cx, h / 2, r + 1, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
ctx.lineWidth = 1;
ctx.stroke();
}
updatePreview() {
this.previewEl.style.backgroundColor = this.getCurrentHex();
}
updateInput() {
const hex = this.getCurrentHex();
if (this.mode === 'HEX') {
this.inputEl.value = hex.toUpperCase();
} else if (this.mode === 'RGB') {
const { r, g, b } = hexToRgb(hex);
this.inputEl.value = `${r}, ${g}, ${b}`;
} else {
const { r, g, b } = hexToRgb(hex);
const { h, s, l } = rgbToHsl(r, g, b);
this.inputEl.value = `${h}, ${s}%, ${l}%`;
}
}
applyInput() {
const val = this.inputEl.value.trim();
let hex = null;
if (this.mode === 'HEX') {
const h = val.startsWith('#') ? val : '#' + val;
if (/^#[0-9a-fA-F]{6}$/.test(h)) hex = h;
} else if (this.mode === 'RGB') {
const p = val.split(',').map(x => parseInt(x.trim()));
if (p.length === 3 && p.every(x => !isNaN(x) && x >= 0 && x <= 255)) hex = rgbToHex(...p);
} else {
const p = val.split(',').map(x => parseFloat(x.trim().replace('%', '')));
if (p.length === 3 && !p.some(isNaN)) hex = hslToHex(p[0], p[1], p[2]);
}
if (hex) {
const { r, g, b } = hexToRgb(hex);
const hsv = rgbToHsv(r, g, b);
this.h = hsv.h; this.s = hsv.s; this.v = hsv.v;
this.renderAll();
}
}
setupEvents() {
const clamp = (v, mn, mx) => Math.max(mn, Math.min(mx, v));
const pickSV = (e) => {
const rect = this.svCanvas.getBoundingClientRect();
this.s = clamp((e.clientX - rect.left) / rect.width, 0, 1) * 100;
this.v = (1 - clamp((e.clientY - rect.top) / rect.height, 0, 1)) * 100;
this.renderAll();
};
const pickHue = (e) => {
const rect = this.hueCanvas.getBoundingClientRect();
this.h = clamp((e.clientX - rect.left) / rect.width, 0, 1) * 360;
this.renderAll();
};
this.svCanvas.addEventListener('mousedown', (e) => { this._dragging = 'sv'; pickSV(e); });
this.hueCanvas.addEventListener('mousedown', (e) => { this._dragging = 'hue'; pickHue(e); });
this._onMouseMove = (e) => {
if (this._dragging === 'sv') pickSV(e);
else if (this._dragging === 'hue') pickHue(e);
};
this._onMouseUp = () => { this._dragging = null; };
document.addEventListener('mousemove', this._onMouseMove);
document.addEventListener('mouseup', this._onMouseUp);
this.inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') this.applyInput(); });
this.inputEl.addEventListener('blur', () => this.applyInput());
}
onClose() {
if (this._onMouseMove) document.removeEventListener('mousemove', this._onMouseMove);
if (this._onMouseUp) document.removeEventListener('mouseup', this._onMouseUp);
this.contentEl.empty();
}
}
// ─── Constants ────────────────────────────────────────────────────────────────
const PRESET_OPTIONS = [
{ value: 'green-snake', label: 'Green Snake' },
{ value: 'midnight-green', label: 'Midnight Green' },
{ value: 'inferno', label: 'Inferno' },
{ value: 'underwater', label: 'Underwater' },
{ value: 'mystic-purple', label: 'Mystic Purple' },
{ value: 'custom', label: 'Custom' },
{ value: 'none', label: 'None' }
];
// Groups that are always active (have a preset selected by default)
const ALWAYS_ON_GROUPS = ['heading', 'bold', 'italic', 'wikilink', 'external'];
// Groups that are opt-in (default 'off')
const OPT_IN_GROUPS = ['normal'];
const ALL_GROUPS = [...ALWAYS_ON_GROUPS, ...OPT_IN_GROUPS];
const DEFAULT_CUSTOM = { color1: '#85ff95', color2: null, color3: '#bcff7d' };
const DEFAULT_SETTINGS = {
disableAllGradients: false,
headingGradient: 'green-snake',
headingCustomColors: { ...DEFAULT_CUSTOM },
boldGradient: 'green-snake',
boldCustomColors: { ...DEFAULT_CUSTOM },
italicGradient: 'green-snake',
italicCustomColors: { ...DEFAULT_CUSTOM },
wikilinkGradient: 'green-snake',
wikilinkCustomColors: { ...DEFAULT_CUSTOM },
externalGradient: 'green-snake',
externalCustomColors: { ...DEFAULT_CUSTOM },
normalGradient: 'off',
normalCustomColors: { ...DEFAULT_CUSTOM }
};
// ─── Settings tab ─────────────────────────────────────────────────────────────
class GradientTextSettingTab extends PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Gradient Text' });
// ── Global toggle (top) ────────────────────────────────────────────────
new Setting(containerEl)
.setName('Disable all gradients')
.setDesc('Turn off all gradient effects and use the theme\'s default text colors.')
.addToggle(toggle => {
toggle
.setValue(this.plugin.settings.disableAllGradients)
.onChange(async value => {
this.plugin.settings.disableAllGradients = value;
await this.plugin.saveSettings();
});
});
// ── Per-element gradient settings ──────────────────────────────────────
this.addGradientSetting('Heading gradient', 'headingGradient', 'heading', 'Heading');
this.addGradientSetting('Normal text gradient', 'normalGradient', 'normal', 'Normal', true);
this.addGradientSetting('Bold gradient', 'boldGradient', 'bold', 'Bold');
this.addGradientSetting('Italic gradient', 'italicGradient', 'italic', 'Italic');
this.addGradientSetting('Wikilink gradient', 'wikilinkGradient', 'wikilink', 'Wikilink');
this.addGradientSetting('External link gradient', 'externalGradient', 'external', 'External');
}
/**
* @param {string} name Display name for the setting row
* @param {string} key Settings key (e.g. 'normalGradient')
* @param {string} group Group name (e.g. 'normal')
* @param {string} previewLabel Short preview text shown right of the dropdown
* @param {boolean} includeOff Whether to prepend an 'Off' option (opt-in groups)
*/
addGradientSetting(name, key, group, previewLabel, includeOff = false) {
const customKey = group + 'CustomColors';
const setting = new Setting(this.containerEl).setName(name);
// ── Color dots (left of dropdown, visible only in 'custom' mode) ───────
const dotsEl = setting.controlEl.createDiv('gh-color-dots');
const renderDots = () => {
dotsEl.empty();
if (this.plugin.settings[key] !== 'custom') {
dotsEl.style.display = 'none';
return;
}
dotsEl.style.display = 'flex';
const colors = this.plugin.settings[customKey];
['color1', 'color2', 'color3'].forEach(ck => {
const dot = dotsEl.createDiv('gh-color-dot');
const color = colors[ck];
if (color) {
dot.style.backgroundColor = color;
} else {
dot.addClass('gh-dot-empty');
}
if (ck === 'color2') {
dot.title = color
? 'Middle color · right-click to remove'
: 'Click to add a middle color (optional)';
}
dot.addEventListener('click', () => {
new ColorPickerModal(this.app, color || '#808080', async newColor => {
this.plugin.settings[customKey][ck] = newColor;
await this.plugin.saveSettings();
renderDots();
}).open();
});
if (ck === 'color2' && color) {
dot.addEventListener('contextmenu', async e => {
e.preventDefault();
this.plugin.settings[customKey].color2 = null;
await this.plugin.saveSettings();
renderDots();
});
}
});
const resetBtn = dotsEl.createDiv('gh-reset-btn');
resetBtn.textContent = '↺';
resetBtn.title = 'Reset to default colors';
resetBtn.addEventListener('click', async () => {
this.plugin.settings[customKey] = { ...DEFAULT_CUSTOM };
await this.plugin.saveSettings();
renderDots();
});
};
renderDots();
// ── Dropdown ───────────────────────────────────────────────────────────
// previewEl is created after, so capture it via late binding
let previewEl = null;
const updatePreviewOff = (value) => {
if (!previewEl) return;
if (value === 'off' || value === 'none') {
previewEl.style.backgroundImage = 'none';
previewEl.style.webkitTextFillColor = 'var(--text-faint)';
} else {
previewEl.style.backgroundImage = '';
previewEl.style.webkitTextFillColor = '';
}
};
setting.addDropdown(dd => {
if (includeOff) dd.addOption('off', 'Off');
PRESET_OPTIONS.forEach(opt => dd.addOption(opt.value, opt.label));
dd.setValue(this.plugin.settings[key])
.onChange(async value => {
this.plugin.settings[key] = value;
await this.plugin.saveSettings();
renderDots();
updatePreviewOff(value);
});
});
// ── Preview label (right of dropdown) ──────────────────────────────────
previewEl = setting.controlEl.createDiv({
cls: `gh-setting-preview gh-preview-${group}`,
text: previewLabel
});
updatePreviewOff(this.plugin.settings[key]);
}
}
// ─── Plugin ───────────────────────────────────────────────────────────────────
module.exports = class GradientTextPlugin extends Plugin {
async onload() {
await this.loadSettings();
this.applyAll();
this.addSettingTab(new GradientTextSettingTab(this.app, this));
// ── Command palette entries ────────────────────────────────────────────
this.addCommand({
id: 'disable-all-gradients',
name: 'Disable all gradients',
callback: async () => {
this.settings.disableAllGradients = true;
await this.saveSettings();
}
});
this.addCommand({
id: 'enable-all-gradients',
name: 'Enable all gradients',
callback: async () => {
this.settings.disableAllGradients = false;
await this.saveSettings();
}
});
this.addCommand({
id: 'toggle-all-gradients',
name: 'Toggle all gradients on / off',
callback: async () => {
this.settings.disableAllGradients = !this.settings.disableAllGradients;
await this.saveSettings();
}
});
this.addCommand({
id: 'toggle-normal-gradient',
name: 'Toggle normal text gradient',
callback: async () => {
this.settings.normalGradient =
this.settings.normalGradient === 'off' ? 'green-snake' : 'off';
await this.saveSettings();
}
});
}
onunload() {
this.clearAll();
}
clearAll() {
const body = document.body;
if (!body) return;
ALL_GROUPS.forEach(group => {
PRESET_OPTIONS
.filter(o => o.value !== 'custom' && o.value !== 'none')
.forEach(opt => body.classList.remove(`gh-${group}-${opt.value}`));
body.classList.remove(`gh-${group}-disabled`);
body.style.removeProperty(`--gh-${group}-gradient`);
body.style.removeProperty(`--gh-${group}-gradient-start`);
body.style.removeProperty(`--gh-${group}-gradient-end`);
if (group === 'wikilink' || group === 'external') {
body.style.removeProperty(`--gh-${group}-underline-color`);
}
});
body.classList.remove('gh-all-gradient-disabled');
body.classList.remove('gh-normal-enabled');
}
applyAll() {
const body = document.body;
if (!body) return;
this.clearAll();
if (this.settings.disableAllGradients) {
body.classList.add('gh-all-gradient-disabled');
return;
}
// Always-on groups (heading, bold, italic, wikilink, external)
ALWAYS_ON_GROUPS.forEach(group => {
const value = this.settings[group + 'Gradient'];
const colors = this.settings[group + 'CustomColors'];
if (value === 'none') {
body.classList.add(`gh-${group}-disabled`);
} else if (value === 'custom') {
body.style.setProperty(`--gh-${group}-gradient`, buildCustomGradient(colors));
body.style.setProperty(`--gh-${group}-gradient-start`, colors.color1 || '#ffffff');
body.style.setProperty(`--gh-${group}-gradient-end`, colors.color3 || '#000000');
if (group === 'wikilink' || group === 'external') {
body.style.setProperty(`--gh-${group}-underline-color`, colors.color1 || '#ffffff');
}
} else {
body.classList.add(`gh-${group}-${value}`);
}
});
// Opt-in group: normal text (default 'off' = no gradient)
const normalValue = this.settings.normalGradient;
const normalColors = this.settings.normalCustomColors;
if (normalValue && normalValue !== 'off' && normalValue !== 'none') {
body.classList.add('gh-normal-enabled');
if (normalValue === 'custom') {
body.style.setProperty('--gh-normal-gradient', buildCustomGradient(normalColors));
body.style.setProperty('--gh-normal-gradient-start', normalColors.color1 || '#ffffff');
body.style.setProperty('--gh-normal-gradient-end', normalColors.color3 || '#000000');
} else {
body.classList.add(`gh-normal-${normalValue}`);
}
}
}
async loadSettings() {
const saved = await this.loadData() || {};
// Migrate old key name from v0.1
if (saved.disableHeadingGradient !== undefined && saved.disableAllGradients === undefined) {
saved.disableAllGradients = saved.disableHeadingGradient;
}
this.settings = Object.assign({}, DEFAULT_SETTINGS, saved);
ALL_GROUPS.forEach(group => {
const k = group + 'CustomColors';
if (!this.settings[k] || typeof this.settings[k] !== 'object') {
this.settings[k] = { ...DEFAULT_CUSTOM };
}
});
}
async saveSettings() {
await this.saveData(this.settings);
this.applyAll();
}
};