Add full gradient editor modal with name, description, visual stop editor, tags, and dirty checking. Gradient editor now supports ID prefix to avoid DOM conflicts between CSS editor and standalone modal. Fix color picker popover clipped by template-card overflow:hidden. Fix gradient canvas not sizing correctly in standalone modal.
466 lines
18 KiB
TypeScript
466 lines
18 KiB
TypeScript
/**
|
||
* 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.ts';
|
||
|
||
/* ── Types ─────────────────────────────────────────────────────── */
|
||
|
||
interface GradientStop {
|
||
position: number;
|
||
color: number[];
|
||
colorRight: number[] | null;
|
||
}
|
||
|
||
interface GradientDragState {
|
||
idx: number;
|
||
trackRect: DOMRect;
|
||
}
|
||
|
||
interface GradientPresetStop {
|
||
position: number;
|
||
color: number[];
|
||
color_right?: number[];
|
||
}
|
||
|
||
interface CustomPreset {
|
||
name: string;
|
||
stops: GradientPresetStop[];
|
||
}
|
||
|
||
/* ── Color conversion utilities ───────────────────────────────── */
|
||
|
||
export function rgbArrayToHex(rgb: number[]): string {
|
||
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: string): number[] {
|
||
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: GradientStop[] = [];
|
||
let _gradientSelectedIdx: number = -1;
|
||
let _gradientDragging: GradientDragState | null = null;
|
||
let _gradientOnChange: (() => void) | null = null;
|
||
let _idPrefix: string = '';
|
||
|
||
/** Set an ID prefix for DOM elements (e.g. 'ge-' to find 'ge-gradient-canvas'). */
|
||
export function gradientSetIdPrefix(prefix: string): void { _idPrefix = prefix; }
|
||
|
||
function _el(id: string): HTMLElement | null { return document.getElementById(_idPrefix + id); }
|
||
|
||
/** Set a callback that fires whenever stops change. */
|
||
export function gradientSetOnChange(fn: (() => void) | null): void { _gradientOnChange = fn; }
|
||
|
||
/** Read-only accessor for save/dirty-check from the parent module. */
|
||
export function getGradientStops(): GradientStop[] {
|
||
return _gradientStops;
|
||
}
|
||
|
||
/* ── Interpolation (mirrors Python backend exactly) ───────────── */
|
||
|
||
function _gradientInterpolate(stops: GradientStop[], pos: number): number[] {
|
||
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: GradientPresetStop[]): void {
|
||
_gradientStops = stops.map(s => ({
|
||
position: parseFloat(String(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: GradientPresetStop[], w: number = 80, h: number = 16): string {
|
||
const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
|
||
return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${css});flex-shrink:0"></span>`;
|
||
}
|
||
|
||
export function applyGradientPreset(key: string): void {
|
||
if (!key || !GRADIENT_PRESETS[key]) return;
|
||
gradientInit(GRADIENT_PRESETS[key]);
|
||
}
|
||
|
||
/* ── Render ───────────────────────────────────────────────────── */
|
||
|
||
export function gradientRenderAll(): void {
|
||
_gradientRenderCanvas();
|
||
_gradientRenderMarkers();
|
||
_gradientRenderStopList();
|
||
if (_gradientOnChange) _gradientOnChange();
|
||
}
|
||
|
||
function _gradientRenderCanvas(): void {
|
||
const canvas = _el('gradient-canvas') as HTMLCanvasElement | null;
|
||
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(): void {
|
||
const track = _el('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: number): void {
|
||
_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(): void {
|
||
const list = _el('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 = `
|
||
<input type="number" class="gradient-stop-pos" value="${stop.position.toFixed(2)}"
|
||
min="0" max="1" step="0.01" title="${t('color_strip.gradient.position')}">
|
||
<input type="color" class="gradient-stop-color" value="${rgbArrayToHex(stop.color)}"
|
||
title="Left color">
|
||
<button type="button" class="btn btn-sm gradient-stop-bidir-btn${hasBidir ? ' active' : ''}"
|
||
title="${t('color_strip.gradient.bidir.hint')}">↔</button>
|
||
<input type="color" class="gradient-stop-color-right" value="${rgbArrayToHex(rightColor)}"
|
||
style="display:${hasBidir ? 'inline-block' : 'none'}" title="Right color">
|
||
<span class="gradient-stop-spacer"></span>
|
||
<button type="button" class="btn btn-sm btn-danger gradient-stop-remove-btn"
|
||
title="Remove stop"${_gradientStops.length <= 2 ? ' disabled' : ''}>✕</button>
|
||
`;
|
||
|
||
// 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 target = e.target as HTMLInputElement;
|
||
const val = Math.min(1, Math.max(0, parseFloat(target.value) || 0));
|
||
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) => {
|
||
const val = (e.target as HTMLInputElement).value;
|
||
_gradientStops[idx].color = hexToRgbArray(val);
|
||
const markers = document.querySelectorAll('.gradient-marker');
|
||
if (markers[idx]) (markers[idx] as HTMLElement).style.background = val;
|
||
_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 as HTMLInputElement).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?: number): void {
|
||
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: MouseEvent, idx: number): void {
|
||
const track = _el('gradient-markers-track');
|
||
if (!track) return;
|
||
_gradientDragging = { idx, trackRect: track.getBoundingClientRect() };
|
||
|
||
const onMove = (me: MouseEvent): void => {
|
||
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 = (): void => {
|
||
_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(): CustomPreset[] {
|
||
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: string): void {
|
||
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: string): void {
|
||
const presets = loadCustomGradientPresets().filter(p => p.name !== name);
|
||
localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets));
|
||
}
|
||
|
||
/* ── Track click → add stop ───────────────────────────────────── */
|
||
|
||
function _gradientSetupTrackClick(): void {
|
||
const track = _el('gradient-markers-track');
|
||
if (!track || (track as any)._gradientClickBound) return;
|
||
(track as any)._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);
|
||
}
|
||
});
|
||
}
|