/** * Gradient stop editor — canvas preview, draggable markers, stop list, presets. * * Extracted from color-strips.js. Self-contained module that manages * gradient stops state and renders into the CSS editor modal DOM. */ import { t } from '../core/i18n.js'; /* ── Color conversion utilities ───────────────────────────────── */ export function rgbArrayToHex(rgb) { if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff'; return '#' + rgb.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join(''); } /** Convert a CSS hex string like "#rrggbb" to an [R, G, B] array. */ export function hexToRgbArray(hex) { const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255]; } /* ── State ────────────────────────────────────────────────────── */ /** * Internal state: array of stop objects. * Each stop: { position: float 0–1, color: [R,G,B], colorRight: [R,G,B]|null } */ let _gradientStops = []; let _gradientSelectedIdx = -1; let _gradientDragging = null; // { idx, trackRect } while dragging let _gradientOnChange = null; /** Set a callback that fires whenever stops change. */ export function gradientSetOnChange(fn) { _gradientOnChange = fn; } /** Read-only accessor for save/dirty-check from the parent module. */ export function getGradientStops() { return _gradientStops; } /* ── Interpolation (mirrors Python backend exactly) ───────────── */ function _gradientInterpolate(stops, pos) { if (!stops.length) return [128, 128, 128]; const sorted = [...stops].sort((a, b) => a.position - b.position); if (pos <= sorted[0].position) return sorted[0].color.slice(); const last = sorted[sorted.length - 1]; if (pos >= last.position) return (last.colorRight || last.color).slice(); for (let i = 0; i < sorted.length - 1; i++) { const a = sorted[i]; const b = sorted[i + 1]; if (a.position <= pos && pos <= b.position) { const span = b.position - a.position; const t2 = span > 0 ? (pos - a.position) / span : 0; const lc = a.colorRight || a.color; const rc = b.color; return lc.map((c, j) => Math.round(c + t2 * (rc[j] - c))); } } return [128, 128, 128]; } /* ── Init ─────────────────────────────────────────────────────── */ export function gradientInit(stops) { _gradientStops = stops.map(s => ({ position: parseFloat(s.position ?? 0), color: (Array.isArray(s.color) && s.color.length === 3) ? [...s.color] : [255, 255, 255], colorRight: (Array.isArray(s.color_right) && s.color_right.length === 3) ? [...s.color_right] : null, })); _gradientSelectedIdx = _gradientStops.length > 0 ? 0 : -1; _gradientDragging = null; _gradientSetupTrackClick(); gradientRenderAll(); } /* ── Presets ──────────────────────────────────────────────────── */ export const GRADIENT_PRESETS = { rainbow: [ { position: 0.0, color: [255, 0, 0] }, { position: 0.17, color: [255, 165, 0] }, { position: 0.33, color: [255, 255, 0] }, { position: 0.5, color: [0, 255, 0] }, { position: 0.67, color: [0, 100, 255] }, { position: 0.83, color: [75, 0, 130] }, { position: 1.0, color: [148, 0, 211] }, ], sunset: [ { position: 0.0, color: [255, 60, 0] }, { position: 0.3, color: [255, 120, 20] }, { position: 0.6, color: [200, 40, 80] }, { position: 0.8, color: [120, 20, 120] }, { position: 1.0, color: [40, 10, 60] }, ], ocean: [ { position: 0.0, color: [0, 10, 40] }, { position: 0.3, color: [0, 60, 120] }, { position: 0.6, color: [0, 140, 180] }, { position: 0.8, color: [100, 220, 240] }, { position: 1.0, color: [200, 240, 255] }, ], forest: [ { position: 0.0, color: [0, 40, 0] }, { position: 0.3, color: [0, 100, 20] }, { position: 0.6, color: [60, 180, 30] }, { position: 0.8, color: [140, 220, 50] }, { position: 1.0, color: [220, 255, 80] }, ], fire: [ { position: 0.0, color: [0, 0, 0] }, { position: 0.25, color: [80, 0, 0] }, { position: 0.5, color: [255, 40, 0] }, { position: 0.75, color: [255, 160, 0] }, { position: 1.0, color: [255, 255, 60] }, ], lava: [ { position: 0.0, color: [0, 0, 0] }, { position: 0.3, color: [120, 0, 0] }, { position: 0.6, color: [255, 60, 0] }, { position: 0.8, color: [255, 160, 40] }, { position: 1.0, color: [255, 255, 120] }, ], aurora: [ { position: 0.0, color: [0, 20, 40] }, { position: 0.25, color: [0, 200, 100] }, { position: 0.5, color: [0, 100, 200] }, { position: 0.75, color: [120, 0, 200] }, { position: 1.0, color: [0, 200, 140] }, ], ice: [ { position: 0.0, color: [255, 255, 255] }, { position: 0.3, color: [180, 220, 255] }, { position: 0.6, color: [80, 160, 255] }, { position: 0.85, color: [20, 60, 180] }, { position: 1.0, color: [10, 20, 80] }, ], warm: [ { position: 0.0, color: [255, 255, 80] }, { position: 0.33, color: [255, 160, 0] }, { position: 0.67, color: [255, 60, 0] }, { position: 1.0, color: [160, 0, 0] }, ], cool: [ { position: 0.0, color: [0, 255, 200] }, { position: 0.33, color: [0, 120, 255] }, { position: 0.67, color: [60, 0, 255] }, { position: 1.0, color: [120, 0, 180] }, ], neon: [ { position: 0.0, color: [255, 0, 200] }, { position: 0.25, color: [0, 255, 255] }, { position: 0.5, color: [0, 255, 50] }, { position: 0.75, color: [255, 255, 0] }, { position: 1.0, color: [255, 0, 100] }, ], pastel: [ { position: 0.0, color: [255, 180, 180] }, { position: 0.2, color: [255, 220, 160] }, { position: 0.4, color: [255, 255, 180] }, { position: 0.6, color: [180, 255, 200] }, { position: 0.8, color: [180, 200, 255] }, { position: 1.0, color: [220, 180, 255] }, ], }; /** * Build a gradient preview from GRADIENT_PRESETS entry (array of {position, color:[r,g,b]}). */ export function gradientPresetStripHTML(stops, w = 80, h = 16) { const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', '); return ``; } export function applyGradientPreset(key) { if (!key || !GRADIENT_PRESETS[key]) return; gradientInit(GRADIENT_PRESETS[key]); } /* ── Render ───────────────────────────────────────────────────── */ export function gradientRenderAll() { _gradientRenderCanvas(); _gradientRenderMarkers(); _gradientRenderStopList(); if (_gradientOnChange) _gradientOnChange(); } function _gradientRenderCanvas() { const canvas = document.getElementById('gradient-canvas'); if (!canvas) return; // Sync canvas pixel width to its CSS display width const W = Math.max(1, Math.round(canvas.offsetWidth || 300)); if (canvas.width !== W) canvas.width = W; const ctx = canvas.getContext('2d'); const H = canvas.height; const imgData = ctx.createImageData(W, H); for (let x = 0; x < W; x++) { const pos = W > 1 ? x / (W - 1) : 0; const [r, g, b] = _gradientInterpolate(_gradientStops, pos); for (let y = 0; y < H; y++) { const idx = (y * W + x) * 4; imgData.data[idx] = r; imgData.data[idx + 1] = g; imgData.data[idx + 2] = b; imgData.data[idx + 3] = 255; } } ctx.putImageData(imgData, 0, 0); } function _gradientRenderMarkers() { const track = document.getElementById('gradient-markers-track'); if (!track) return; track.innerHTML = ''; _gradientStops.forEach((stop, idx) => { const marker = document.createElement('div'); marker.className = 'gradient-marker' + (idx === _gradientSelectedIdx ? ' selected' : ''); marker.style.left = `${stop.position * 100}%`; marker.style.background = rgbArrayToHex(stop.color); marker.title = `${(stop.position * 100).toFixed(0)}%`; marker.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); _gradientSelectedIdx = idx; _gradientStartDrag(e, idx); _gradientRenderMarkers(); _gradientRenderStopList(); }); track.appendChild(marker); }); } /** * Update the selected stop index and reflect it via CSS classes only — * no DOM rebuild, so in-flight click events on child elements are preserved. */ function _gradientSelectStop(idx) { _gradientSelectedIdx = idx; document.querySelectorAll('.gradient-stop-row').forEach((r, i) => r.classList.toggle('selected', i === idx)); document.querySelectorAll('.gradient-marker').forEach((m, i) => m.classList.toggle('selected', i === idx)); } function _gradientRenderStopList() { const list = document.getElementById('gradient-stops-list'); if (!list) return; list.innerHTML = ''; _gradientStops.forEach((stop, idx) => { const row = document.createElement('div'); row.className = 'gradient-stop-row' + (idx === _gradientSelectedIdx ? ' selected' : ''); const hasBidir = !!stop.colorRight; const rightColor = stop.colorRight || stop.color; row.innerHTML = ` `; // Select row on mousedown — CSS-only update so child click events are not interrupted row.addEventListener('mousedown', () => _gradientSelectStop(idx)); // Position const posInput = row.querySelector('.gradient-stop-pos'); posInput.addEventListener('change', (e) => { const val = Math.min(1, Math.max(0, parseFloat(e.target.value) || 0)); e.target.value = val.toFixed(2); _gradientStops[idx].position = val; gradientRenderAll(); }); posInput.addEventListener('focus', () => _gradientSelectStop(idx)); // Left color row.querySelector('.gradient-stop-color').addEventListener('input', (e) => { _gradientStops[idx].color = hexToRgbArray(e.target.value); const markers = document.querySelectorAll('.gradient-marker'); if (markers[idx]) markers[idx].style.background = e.target.value; _gradientRenderCanvas(); }); // Bidirectional toggle row.querySelector('.gradient-stop-bidir-btn').addEventListener('click', (e) => { e.stopPropagation(); _gradientStops[idx].colorRight = _gradientStops[idx].colorRight ? null : [..._gradientStops[idx].color]; _gradientRenderStopList(); _gradientRenderCanvas(); }); // Right color row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => { _gradientStops[idx].colorRight = hexToRgbArray(e.target.value); _gradientRenderCanvas(); }); // Remove row.querySelector('.btn-danger').addEventListener('click', (e) => { e.stopPropagation(); if (_gradientStops.length > 2) { _gradientStops.splice(idx, 1); if (_gradientSelectedIdx >= _gradientStops.length) { _gradientSelectedIdx = _gradientStops.length - 1; } gradientRenderAll(); } }); list.appendChild(row); }); } /* ── Add Stop ─────────────────────────────────────────────────── */ export function gradientAddStop(position) { if (position === undefined) { // Find the largest gap between adjacent stops and place in the middle const sorted = [..._gradientStops].sort((a, b) => a.position - b.position); let maxGap = 0, gapMid = 0.5; for (let i = 0; i < sorted.length - 1; i++) { const gap = sorted[i + 1].position - sorted[i].position; if (gap > maxGap) { maxGap = gap; gapMid = (sorted[i].position + sorted[i + 1].position) / 2; } } position = sorted.length >= 2 ? Math.round(gapMid * 100) / 100 : 0.5; } position = Math.min(1, Math.max(0, position)); const color = _gradientInterpolate(_gradientStops, position); _gradientStops.push({ position, color, colorRight: null }); _gradientSelectedIdx = _gradientStops.length - 1; gradientRenderAll(); } /* ── Drag ─────────────────────────────────────────────────────── */ function _gradientStartDrag(e, idx) { const track = document.getElementById('gradient-markers-track'); if (!track) return; _gradientDragging = { idx, trackRect: track.getBoundingClientRect() }; const onMove = (me) => { if (!_gradientDragging) return; const { trackRect } = _gradientDragging; const pos = Math.min(1, Math.max(0, (me.clientX - trackRect.left) / trackRect.width)); _gradientStops[_gradientDragging.idx].position = Math.round(pos * 100) / 100; gradientRenderAll(); }; const onUp = () => { _gradientDragging = null; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } /* ── Custom presets (localStorage) ───────────────────────────── */ const _CUSTOM_PRESETS_KEY = 'custom_gradient_presets'; /** Load custom presets from localStorage. Returns an array of { name, stops }. */ export function loadCustomGradientPresets() { try { return JSON.parse(localStorage.getItem(_CUSTOM_PRESETS_KEY) || '[]'); } catch { return []; } } /** Save the current gradient stops as a named custom preset. */ export function saveCurrentAsCustomPreset(name) { if (!name) return; const stops = _gradientStops.map(s => ({ position: s.position, color: [...s.color], ...(s.colorRight ? { color_right: [...s.colorRight] } : {}), })); const presets = loadCustomGradientPresets(); // Replace if same name exists const idx = presets.findIndex(p => p.name === name); if (idx >= 0) presets[idx] = { name, stops }; else presets.push({ name, stops }); localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets)); } /** Delete a custom preset by name. */ export function deleteCustomGradientPreset(name) { const presets = loadCustomGradientPresets().filter(p => p.name !== name); localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets)); } /* ── Track click → add stop ───────────────────────────────────── */ function _gradientSetupTrackClick() { const track = document.getElementById('gradient-markers-track'); if (!track || track._gradientClickBound) return; track._gradientClickBound = true; track.addEventListener('click', (e) => { if (_gradientDragging) return; const rect = track.getBoundingClientRect(); const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); // Ignore clicks very close to an existing marker const tooClose = _gradientStops.some(s => Math.abs(s.position - pos) < 0.03); if (!tooClose) { gradientAddStop(Math.round(pos * 100) / 100); } }); }