mnfst 0.5.62 → 0.5.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/manifest.colorpicker.js +3033 -0
- package/lib/manifest.dropdowns.js +1 -1
- package/lib/manifest.icons.js +1 -1
- package/lib/manifest.js +8 -11
- package/lib/manifest.localization.js +1 -1
- package/lib/manifest.markdown.js +21 -1
- package/lib/manifest.resize.js +1 -1
- package/lib/manifest.slides.js +1 -1
- package/lib/manifest.themes.js +1 -1
- package/lib/manifest.toasts.js +1 -1
- package/lib/manifest.tooltips.js +1 -1
- package/lib/manifest.url.parameters.js +236 -0
- package/package.json +3 -2
|
@@ -0,0 +1,3033 @@
|
|
|
1
|
+
/* Manifest Color Picker
|
|
2
|
+
|
|
3
|
+
Directive: x-colorpicker[.<modifier>[="value"]]
|
|
4
|
+
|
|
5
|
+
Root (no modifier): declares a picker container.
|
|
6
|
+
Children (with modifier): declare hooks. Three families:
|
|
7
|
+
|
|
8
|
+
• Layout (template OR instance — determined by host element tag)
|
|
9
|
+
.solid canvas + hue/alpha sliders + value + format
|
|
10
|
+
.gradient full gradient panel (layers container + layer template)
|
|
11
|
+
.layer-options one gradient layer's UI
|
|
12
|
+
.gradient-layers container holding cloned .layer-options instances
|
|
13
|
+
.layer-stops-bar stops bar (inside a layer)
|
|
14
|
+
|
|
15
|
+
• Actions (click triggers behavior)
|
|
16
|
+
.add-layer .apply-color .grab-color
|
|
17
|
+
.duplicate-layer .remove-layer
|
|
18
|
+
.flip-layer .rotate-layer
|
|
19
|
+
.duplicate-stop .delete-stop
|
|
20
|
+
.set-gradient-type="linear|radial|conic"
|
|
21
|
+
|
|
22
|
+
• Inputs (state-bound control)
|
|
23
|
+
.set-canvas .set-hue .set-alpha
|
|
24
|
+
.set-alpha-value .set-color-space .set-color-value
|
|
25
|
+
.set-angle .set-gradient-value
|
|
26
|
+
|
|
27
|
+
Magic: $colorpicker — returns the nearest ancestor picker's API
|
|
28
|
+
for programmatic control (addLayer, setHue, layers, activeStop, ...).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
function initializeColorpickerPlugin() {
|
|
32
|
+
|
|
33
|
+
// ---- Parse any CSS color string via the browser ----
|
|
34
|
+
|
|
35
|
+
const parseCtx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
36
|
+
|
|
37
|
+
function parseCssColor(str) {
|
|
38
|
+
if (!str || typeof str !== 'string') return null;
|
|
39
|
+
str = str.trim();
|
|
40
|
+
if (!str) return null;
|
|
41
|
+
const hex8 = str.match(/^#([0-9a-f]{8})$/i);
|
|
42
|
+
if (hex8) {
|
|
43
|
+
const n = parseInt(hex8[1], 16);
|
|
44
|
+
return { r: (n >> 24) & 255, g: (n >> 16) & 255, b: (n >> 8) & 255, a: (n & 255) / 255 };
|
|
45
|
+
}
|
|
46
|
+
const hex4 = str.match(/^#([0-9a-f]{4})$/i);
|
|
47
|
+
if (hex4) {
|
|
48
|
+
const c = hex4[1];
|
|
49
|
+
return { r: parseInt(c[0]+c[0],16), g: parseInt(c[1]+c[1],16), b: parseInt(c[2]+c[2],16), a: parseInt(c[3]+c[3],16)/255 };
|
|
50
|
+
}
|
|
51
|
+
parseCtx.clearRect(0, 0, 1, 1);
|
|
52
|
+
parseCtx.fillStyle = '#00000000';
|
|
53
|
+
parseCtx.fillStyle = str;
|
|
54
|
+
if (parseCtx.fillStyle === '#00000000' && str !== '#00000000' && str !== 'transparent') {
|
|
55
|
+
parseCtx.fillStyle = '#01010101';
|
|
56
|
+
parseCtx.fillStyle = str;
|
|
57
|
+
if (parseCtx.fillStyle === '#01010101') return null;
|
|
58
|
+
}
|
|
59
|
+
parseCtx.fillRect(0, 0, 1, 1);
|
|
60
|
+
const d = parseCtx.getImageData(0, 0, 1, 1).data;
|
|
61
|
+
return { r: d[0], g: d[1], b: d[2], a: +(d[3] / 255).toFixed(3) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- Color conversions ----
|
|
65
|
+
|
|
66
|
+
function rgbToHex(r, g, b) { return '#' + [r,g,b].map(v => Math.round(v).toString(16).padStart(2,'0')).join(''); }
|
|
67
|
+
function rgbToHex8(r, g, b, a) { return '#' + [r,g,b,Math.round(a*255)].map(v => Math.round(v).toString(16).padStart(2,'0')).join(''); }
|
|
68
|
+
|
|
69
|
+
function rgbToHsv(r, g, b) {
|
|
70
|
+
r/=255; g/=255; b/=255;
|
|
71
|
+
const max=Math.max(r,g,b), min=Math.min(r,g,b), d=max-min;
|
|
72
|
+
let h=0, s=max===0?0:d/max, v=max;
|
|
73
|
+
if(d!==0){ if(max===r)h=((g-b)/d+(g<b?6:0))/6; else if(max===g)h=((b-r)/d+2)/6; else h=((r-g)/d+4)/6; }
|
|
74
|
+
return {h:h*360, s:s*100, v:v*100};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hsvToRgb(h, s, v) {
|
|
78
|
+
h/=360; s/=100; v/=100;
|
|
79
|
+
let r,g,b; const i=Math.floor(h*6), f=h*6-i, p=v*(1-s), q=v*(1-f*s), t=v*(1-(1-f)*s);
|
|
80
|
+
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; }
|
|
81
|
+
return {r:Math.round(r*255), g:Math.round(g*255), b:Math.round(b*255)};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rgbToHsl(r, g, b) {
|
|
85
|
+
r/=255; g/=255; b/=255;
|
|
86
|
+
const max=Math.max(r,g,b), min=Math.min(r,g,b), l=(max+min)/2;
|
|
87
|
+
let h=0, s=0;
|
|
88
|
+
if(max!==min){ const d=max-min; s=l>0.5?d/(2-max-min):d/(max+min); if(max===r)h=((g-b)/d+(g<b?6:0))/6; else if(max===g)h=((b-r)/d+2)/6; else h=((r-g)/d+4)/6; }
|
|
89
|
+
return {h:Math.round(h*360), s:Math.round(s*100), l:Math.round(l*100)};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function srgbToLinear(c){ return c<=0.04045?c/12.92:Math.pow((c+0.055)/1.055,2.4); }
|
|
93
|
+
|
|
94
|
+
function rgbToOklch(r, g, b) {
|
|
95
|
+
const lr=srgbToLinear(r/255), lg=srgbToLinear(g/255), lb=srgbToLinear(b/255);
|
|
96
|
+
const l_=0.4122214708*lr+0.5363325363*lg+0.0514459929*lb, m_=0.2119034982*lr+0.6806995451*lg+0.1073969566*lb, s_=0.0883024619*lr+0.2817188376*lg+0.6299787005*lb;
|
|
97
|
+
const l1=Math.cbrt(l_), m1=Math.cbrt(m_), s1=Math.cbrt(s_);
|
|
98
|
+
const L=0.2104542553*l1+0.7936177850*m1-0.0040720468*s1, a=1.9779984951*l1-2.4285922050*m1+0.4505937099*s1, bk=0.0259040371*l1+0.7827717662*m1-0.8086757660*s1;
|
|
99
|
+
let H=Math.atan2(bk,a)*180/Math.PI; if(H<0)H+=360;
|
|
100
|
+
return {l:+(L*100).toFixed(2), c:+Math.sqrt(a*a+bk*bk).toFixed(4), h:+H.toFixed(1)};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const FORMATS = ['hex', 'rgb', 'hsl', 'oklch'];
|
|
104
|
+
|
|
105
|
+
function roundA(a) { const v=Math.round(a*100); return v===100?'1':(v/100).toString(); }
|
|
106
|
+
|
|
107
|
+
// Canonical dedupe key for a color or gradient value. Used to tag library swatches
|
|
108
|
+
// with a `data-cp-key` that the picker can match against its current color to toggle
|
|
109
|
+
// an `active` class. Parses any CSS color via parseCssColor → 8-digit hex. Gradients
|
|
110
|
+
// are compared as their normalized CSS string.
|
|
111
|
+
function _swatchKeyOf(value) {
|
|
112
|
+
if (typeof value !== 'string') return null;
|
|
113
|
+
const v = value.trim();
|
|
114
|
+
if (!v) return null;
|
|
115
|
+
if (v.includes('gradient(')) return v;
|
|
116
|
+
const c = parseCssColor(v);
|
|
117
|
+
if (!c) return null;
|
|
118
|
+
return rgbToHex8(c.r, c.g, c.b, c.a);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function formatColor(r, g, b, a, mode) {
|
|
122
|
+
const hasA = a < 1;
|
|
123
|
+
switch (mode) {
|
|
124
|
+
case 'hex': return hasA ? rgbToHex8(r,g,b,a) : rgbToHex(r,g,b);
|
|
125
|
+
case 'rgb': return `rgb(${r} ${g} ${b}${hasA?' / '+roundA(a):''})`;
|
|
126
|
+
case 'hsl': { const c=rgbToHsl(r,g,b); return `hsl(${c.h} ${c.s}% ${c.l}%${hasA?' / '+roundA(a):''})`; }
|
|
127
|
+
case 'oklch': { const c=rgbToOklch(r,g,b); return `oklch(${c.l}% ${c.c} ${c.h}${hasA?' / '+roundA(a):''})`; }
|
|
128
|
+
default: return rgbToHex(r,g,b);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function detectFormat(str) {
|
|
133
|
+
str = (str || '').trim().toLowerCase();
|
|
134
|
+
if (str.startsWith('#')) return 'hex';
|
|
135
|
+
if (str.startsWith('rgb')) return 'rgb';
|
|
136
|
+
if (str.startsWith('hsl')) return 'hsl';
|
|
137
|
+
if (str.startsWith('oklch')) return 'oklch';
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function colorToRgba(col) {
|
|
142
|
+
const {r,g,b} = hsvToRgb(col.h, col.s, col.v);
|
|
143
|
+
return col.a < 1 ? `rgba(${r},${g},${b},${col.a})` : rgbToHex(r,g,b);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- Canvas (SV plane) ----
|
|
147
|
+
|
|
148
|
+
function drawSvCanvas(canvas, hue) {
|
|
149
|
+
// Cache the 2D context on the canvas element. No `willReadFrequently` —
|
|
150
|
+
// we only ever write to this canvas, so we want GPU-accelerated compositing.
|
|
151
|
+
const ctx = canvas._cpCtx || (canvas._cpCtx = canvas.getContext('2d'));
|
|
152
|
+
const w = canvas.width, h = canvas.height;
|
|
153
|
+
const hr = hsvToRgb(hue, 100, 100);
|
|
154
|
+
const hG = ctx.createLinearGradient(0,0,w,0);
|
|
155
|
+
hG.addColorStop(0,'#fff'); hG.addColorStop(1,`rgb(${hr.r},${hr.g},${hr.b})`);
|
|
156
|
+
ctx.fillStyle = hG; ctx.fillRect(0,0,w,h);
|
|
157
|
+
const vG = ctx.createLinearGradient(0,0,0,h);
|
|
158
|
+
vG.addColorStop(0,'rgba(0,0,0,0)'); vG.addColorStop(1,'#000');
|
|
159
|
+
ctx.fillStyle = vG; ctx.fillRect(0,0,w,h);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- Gradient string builders ----
|
|
163
|
+
|
|
164
|
+
const GRADIENT_TYPES = ['linear', 'radial', 'conic'];
|
|
165
|
+
|
|
166
|
+
function buildLayerString(layer) {
|
|
167
|
+
const stops = layer.stops.slice().sort((a,b) => a.position - b.position)
|
|
168
|
+
.map(s => `${colorToRgba(s.color)} ${s.position}%`).join(', ');
|
|
169
|
+
switch (layer.type) {
|
|
170
|
+
case 'linear': return `linear-gradient(${layer.angle}deg, ${stops})`;
|
|
171
|
+
case 'radial': return `radial-gradient(circle at ${layer.position.x}% ${layer.position.y}%, ${stops})`;
|
|
172
|
+
case 'conic': return `conic-gradient(from ${layer.angle}deg at ${layer.position.x}% ${layer.position.y}%, ${stops})`;
|
|
173
|
+
default: return `linear-gradient(${layer.angle}deg, ${stops})`;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildFullGradientString(layers) {
|
|
178
|
+
return layers.map(buildLayerString).join(', ');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- Gradient parsing (reverse of buildLayerString / buildFullGradientString) ----
|
|
182
|
+
//
|
|
183
|
+
// Parses a CSS gradient string back into the layers/stops structure the picker
|
|
184
|
+
// uses internally. Tolerant of whatever the browser serializes from inline
|
|
185
|
+
// styles (rgb(...) and rgba(...) instead of hex, etc.).
|
|
186
|
+
|
|
187
|
+
// Split a string on top-level commas (commas not inside parentheses).
|
|
188
|
+
function _splitTopLevelCommas(str) {
|
|
189
|
+
const out = [];
|
|
190
|
+
let depth = 0, start = 0;
|
|
191
|
+
for (let i = 0; i < str.length; i++) {
|
|
192
|
+
const c = str[i];
|
|
193
|
+
if (c === '(') depth++;
|
|
194
|
+
else if (c === ')') depth--;
|
|
195
|
+
else if (c === ',' && depth === 0) { out.push(str.slice(start, i).trim()); start = i + 1; }
|
|
196
|
+
}
|
|
197
|
+
if (start < str.length) out.push(str.slice(start).trim());
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Split a string into individual gradient calls — top-level segments that each
|
|
202
|
+
// start with `<type>-gradient(`. Handles multi-layer gradients like
|
|
203
|
+
// "linear-gradient(...), radial-gradient(...)".
|
|
204
|
+
function _splitGradientLayers(str) {
|
|
205
|
+
const segments = _splitTopLevelCommas(str);
|
|
206
|
+
return segments.filter(s => /^(linear|radial|conic)-gradient\s*\(/i.test(s));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function _parseGradientLayer(layerStr) {
|
|
210
|
+
const m = layerStr.match(/^(linear|radial|conic)-gradient\s*\(([\s\S]*)\)\s*$/i);
|
|
211
|
+
if (!m) return null;
|
|
212
|
+
const type = m[1].toLowerCase();
|
|
213
|
+
const parts = _splitTopLevelCommas(m[2]);
|
|
214
|
+
|
|
215
|
+
let angle = 90, x = 50, y = 50;
|
|
216
|
+
let stopsStart = 0;
|
|
217
|
+
const first = parts[0] || '';
|
|
218
|
+
|
|
219
|
+
if (type === 'linear') {
|
|
220
|
+
// "<angle>deg" prefix is optional; default 90 if absent
|
|
221
|
+
const am = first.match(/^([-\d.]+)\s*deg/i);
|
|
222
|
+
if (am) { angle = parseFloat(am[1]); stopsStart = 1; }
|
|
223
|
+
} else if (type === 'radial') {
|
|
224
|
+
// "circle at X% Y%" or similar — pull X/Y if present
|
|
225
|
+
const pm = first.match(/at\s+([-\d.]+)%\s+([-\d.]+)%/i);
|
|
226
|
+
if (pm) { x = parseFloat(pm[1]); y = parseFloat(pm[2]); stopsStart = 1; }
|
|
227
|
+
else if (/^(circle|ellipse|closest|farthest)/i.test(first)) stopsStart = 1;
|
|
228
|
+
} else if (type === 'conic') {
|
|
229
|
+
const am = first.match(/from\s+([-\d.]+)\s*deg/i);
|
|
230
|
+
const pm = first.match(/at\s+([-\d.]+)%\s+([-\d.]+)%/i);
|
|
231
|
+
if (am) angle = parseFloat(am[1]);
|
|
232
|
+
if (pm) { x = parseFloat(pm[1]); y = parseFloat(pm[2]); }
|
|
233
|
+
if (am || pm) stopsStart = 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const stops = parts.slice(stopsStart).map(_parseGradientStop).filter(Boolean);
|
|
237
|
+
if (stops.length === 0) return null;
|
|
238
|
+
return { type, angle, position: { x, y }, stops };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Parses "<color> <position>%" — color may be any CSS color form
|
|
242
|
+
// (hex, rgb(), rgba(), hsl(), oklch(), named, etc.).
|
|
243
|
+
function _parseGradientStop(s) {
|
|
244
|
+
const trimmed = s.trim();
|
|
245
|
+
// Position is the trailing "<num>%" — color is everything before
|
|
246
|
+
const m = trimmed.match(/^(.+?)\s+([-\d.]+)%\s*$/);
|
|
247
|
+
if (!m) return null;
|
|
248
|
+
const rgb = parseCssColor(m[1].trim());
|
|
249
|
+
if (!rgb) return null;
|
|
250
|
+
const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b);
|
|
251
|
+
return {
|
|
252
|
+
color: { h: hsv.h, s: hsv.s, v: hsv.v, a: rgb.a },
|
|
253
|
+
position: parseFloat(m[2]),
|
|
254
|
+
format: 'hex'
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseGradientString(str) {
|
|
259
|
+
if (typeof str !== 'string') return null;
|
|
260
|
+
const trimmed = str.trim();
|
|
261
|
+
if (!/gradient\s*\(/i.test(trimmed)) return null;
|
|
262
|
+
const layerStrs = _splitGradientLayers(trimmed);
|
|
263
|
+
if (layerStrs.length === 0) return null;
|
|
264
|
+
const layers = layerStrs.map(_parseGradientLayer).filter(Boolean);
|
|
265
|
+
return layers.length ? layers : null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---- Layer / stop factories ----
|
|
269
|
+
|
|
270
|
+
function makeDefaultStop(hue, position) {
|
|
271
|
+
return { color: { h: hue, s: 100, v: 100, a: 1 }, position, format: 'hex' };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function makeDefaultLayer() {
|
|
275
|
+
return {
|
|
276
|
+
type: 'linear', angle: 90, position: { x: 50, y: 50 },
|
|
277
|
+
stops: [ makeDefaultStop(0, 0), makeDefaultStop(240, 100) ]
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---- Built-in preset palettes ----
|
|
282
|
+
|
|
283
|
+
// Tailwind v4 default theme (exact OKLCH values from tailwindcss/theme.css)
|
|
284
|
+
// Ref: tailwindlabs/tailwindcss — packages/tailwindcss/theme.css
|
|
285
|
+
const TAILWIND_HUES = ['red','orange','amber','yellow','lime','green','emerald','teal','cyan','sky','blue','indigo','violet','purple','fuchsia','pink','rose','slate','gray','zinc','neutral','stone','mauve','olive','mist','taupe'];
|
|
286
|
+
const TAILWIND_SHADES = ['50','100','200','300','400','500','600','700','800','900','950'];
|
|
287
|
+
const TAILWIND_COLORS = {
|
|
288
|
+
red: ['oklch(97.1% 0.013 17.38)','oklch(93.6% 0.032 17.717)','oklch(88.5% 0.062 18.334)','oklch(80.8% 0.114 19.571)','oklch(70.4% 0.191 22.216)','oklch(63.7% 0.237 25.331)','oklch(57.7% 0.245 27.325)','oklch(50.5% 0.213 27.518)','oklch(44.4% 0.177 26.899)','oklch(39.6% 0.141 25.723)','oklch(25.8% 0.092 26.042)'],
|
|
289
|
+
orange: ['oklch(98% 0.016 73.684)','oklch(95.4% 0.038 75.164)','oklch(90.1% 0.076 70.697)','oklch(83.7% 0.128 66.29)','oklch(75% 0.183 55.934)','oklch(70.5% 0.213 47.604)','oklch(64.6% 0.222 41.116)','oklch(55.3% 0.195 38.402)','oklch(47% 0.157 37.304)','oklch(40.8% 0.123 38.172)','oklch(26.6% 0.079 36.259)'],
|
|
290
|
+
amber: ['oklch(98.7% 0.022 95.277)','oklch(96.2% 0.059 95.617)','oklch(92.4% 0.12 95.746)','oklch(87.9% 0.169 91.605)','oklch(82.8% 0.189 84.429)','oklch(76.9% 0.188 70.08)','oklch(66.6% 0.179 58.318)','oklch(55.5% 0.163 48.998)','oklch(47.3% 0.137 46.201)','oklch(41.4% 0.112 45.904)','oklch(27.9% 0.077 45.635)'],
|
|
291
|
+
yellow: ['oklch(98.7% 0.026 102.212)','oklch(97.3% 0.071 103.193)','oklch(94.5% 0.129 101.54)','oklch(90.5% 0.182 98.111)','oklch(85.2% 0.199 91.936)','oklch(79.5% 0.184 86.047)','oklch(68.1% 0.162 75.834)','oklch(55.4% 0.135 66.442)','oklch(47.6% 0.114 61.907)','oklch(42.1% 0.095 57.708)','oklch(28.6% 0.066 53.813)'],
|
|
292
|
+
lime: ['oklch(98.6% 0.031 120.757)','oklch(96.7% 0.067 122.328)','oklch(93.8% 0.127 124.321)','oklch(89.7% 0.196 126.665)','oklch(84.1% 0.238 128.85)','oklch(76.8% 0.233 130.85)','oklch(64.8% 0.2 131.684)','oklch(53.2% 0.157 131.589)','oklch(45.3% 0.124 130.933)','oklch(40.5% 0.101 131.063)','oklch(27.4% 0.072 132.109)'],
|
|
293
|
+
green: ['oklch(98.2% 0.018 155.826)','oklch(96.2% 0.044 156.743)','oklch(92.5% 0.084 155.995)','oklch(87.1% 0.15 154.449)','oklch(79.2% 0.209 151.711)','oklch(72.3% 0.219 149.579)','oklch(62.7% 0.194 149.214)','oklch(52.7% 0.154 150.069)','oklch(44.8% 0.119 151.328)','oklch(39.3% 0.095 152.535)','oklch(26.6% 0.065 152.934)'],
|
|
294
|
+
emerald: ['oklch(97.9% 0.021 166.113)','oklch(95% 0.052 163.051)','oklch(90.5% 0.093 164.15)','oklch(84.5% 0.143 164.978)','oklch(76.5% 0.177 163.223)','oklch(69.6% 0.17 162.48)','oklch(59.6% 0.145 163.225)','oklch(50.8% 0.118 165.612)','oklch(43.2% 0.095 166.913)','oklch(37.8% 0.077 168.94)','oklch(26.2% 0.051 172.552)'],
|
|
295
|
+
teal: ['oklch(98.4% 0.014 180.72)','oklch(95.3% 0.051 180.801)','oklch(91% 0.096 180.426)','oklch(85.5% 0.138 181.071)','oklch(77.7% 0.152 181.912)','oklch(70.4% 0.14 182.503)','oklch(60% 0.118 184.704)','oklch(51.1% 0.096 186.391)','oklch(43.7% 0.078 188.216)','oklch(38.6% 0.063 188.416)','oklch(27.7% 0.046 192.524)'],
|
|
296
|
+
cyan: ['oklch(98.4% 0.019 200.873)','oklch(95.6% 0.045 203.388)','oklch(91.7% 0.08 205.041)','oklch(86.5% 0.127 207.078)','oklch(78.9% 0.154 211.53)','oklch(71.5% 0.143 215.221)','oklch(60.9% 0.126 221.723)','oklch(52% 0.105 223.128)','oklch(45% 0.085 224.283)','oklch(39.8% 0.07 227.392)','oklch(30.2% 0.056 229.695)'],
|
|
297
|
+
sky: ['oklch(97.7% 0.013 236.62)','oklch(95.1% 0.026 236.824)','oklch(90.1% 0.058 230.902)','oklch(82.8% 0.111 230.318)','oklch(74.6% 0.16 232.661)','oklch(68.5% 0.169 237.323)','oklch(58.8% 0.158 241.966)','oklch(50% 0.134 242.749)','oklch(44.3% 0.11 240.79)','oklch(39.1% 0.09 240.876)','oklch(29.3% 0.066 243.157)'],
|
|
298
|
+
blue: ['oklch(97% 0.014 254.604)','oklch(93.2% 0.032 255.585)','oklch(88.2% 0.059 254.128)','oklch(80.9% 0.105 251.813)','oklch(70.7% 0.165 254.624)','oklch(62.3% 0.214 259.815)','oklch(54.6% 0.245 262.881)','oklch(48.8% 0.243 264.376)','oklch(42.4% 0.199 265.638)','oklch(37.9% 0.146 265.522)','oklch(28.2% 0.091 267.935)'],
|
|
299
|
+
indigo: ['oklch(96.2% 0.018 272.314)','oklch(93% 0.034 272.788)','oklch(87% 0.065 274.039)','oklch(78.5% 0.115 274.713)','oklch(67.3% 0.182 276.935)','oklch(58.5% 0.233 277.117)','oklch(51.1% 0.262 276.966)','oklch(45.7% 0.24 277.023)','oklch(39.8% 0.195 277.366)','oklch(35.9% 0.144 278.697)','oklch(25.7% 0.09 281.288)'],
|
|
300
|
+
violet: ['oklch(96.9% 0.016 293.756)','oklch(94.3% 0.029 294.588)','oklch(89.4% 0.057 293.283)','oklch(81.1% 0.111 293.571)','oklch(70.2% 0.183 293.541)','oklch(60.6% 0.25 292.717)','oklch(54.1% 0.281 293.009)','oklch(49.1% 0.27 292.581)','oklch(43.2% 0.232 292.759)','oklch(38% 0.189 293.745)','oklch(28.3% 0.141 291.089)'],
|
|
301
|
+
purple: ['oklch(97.7% 0.014 308.299)','oklch(94.6% 0.033 307.174)','oklch(90.2% 0.063 306.703)','oklch(82.7% 0.119 306.383)','oklch(71.4% 0.203 305.504)','oklch(62.7% 0.265 303.9)','oklch(55.8% 0.288 302.321)','oklch(49.6% 0.265 301.924)','oklch(43.8% 0.218 303.724)','oklch(38.1% 0.176 304.987)','oklch(29.1% 0.149 302.717)'],
|
|
302
|
+
fuchsia: ['oklch(97.7% 0.017 320.058)','oklch(95.2% 0.037 318.852)','oklch(90.3% 0.076 319.62)','oklch(83.3% 0.145 321.434)','oklch(74% 0.238 322.16)','oklch(66.7% 0.295 322.15)','oklch(59.1% 0.293 322.896)','oklch(51.8% 0.253 323.949)','oklch(45.2% 0.211 324.591)','oklch(40.1% 0.17 325.612)','oklch(29.3% 0.136 325.661)'],
|
|
303
|
+
pink: ['oklch(97.1% 0.014 343.198)','oklch(94.8% 0.028 342.258)','oklch(89.9% 0.061 343.231)','oklch(82.3% 0.12 346.018)','oklch(71.8% 0.202 349.761)','oklch(65.6% 0.241 354.308)','oklch(59.2% 0.249 0.584)','oklch(52.5% 0.223 3.958)','oklch(45.9% 0.187 3.815)','oklch(40.8% 0.153 2.432)','oklch(28.4% 0.109 3.907)'],
|
|
304
|
+
rose: ['oklch(96.9% 0.015 12.422)','oklch(94.1% 0.03 12.58)','oklch(89.2% 0.058 10.001)','oklch(81% 0.117 11.638)','oklch(71.2% 0.194 13.428)','oklch(64.5% 0.246 16.439)','oklch(58.6% 0.253 17.585)','oklch(51.4% 0.222 16.935)','oklch(45.5% 0.188 13.697)','oklch(41% 0.159 10.272)','oklch(27.1% 0.105 12.094)'],
|
|
305
|
+
slate: ['oklch(98.4% 0.003 247.858)','oklch(96.8% 0.007 247.896)','oklch(92.9% 0.013 255.508)','oklch(86.9% 0.022 252.894)','oklch(70.4% 0.04 256.788)','oklch(55.4% 0.046 257.417)','oklch(44.6% 0.043 257.281)','oklch(37.2% 0.044 257.287)','oklch(27.9% 0.041 260.031)','oklch(20.8% 0.042 265.755)','oklch(12.9% 0.042 264.695)'],
|
|
306
|
+
gray: ['oklch(98.5% 0.002 247.839)','oklch(96.7% 0.003 264.542)','oklch(92.8% 0.006 264.531)','oklch(87.2% 0.01 258.338)','oklch(70.7% 0.022 261.325)','oklch(55.1% 0.027 264.364)','oklch(44.6% 0.03 256.802)','oklch(37.3% 0.034 259.733)','oklch(27.8% 0.033 256.848)','oklch(21% 0.034 264.665)','oklch(13% 0.028 261.692)'],
|
|
307
|
+
zinc: ['oklch(98.5% 0 0)','oklch(96.7% 0.001 286.375)','oklch(92% 0.004 286.32)','oklch(87.1% 0.006 286.286)','oklch(70.5% 0.015 286.067)','oklch(55.2% 0.016 285.938)','oklch(44.2% 0.017 285.786)','oklch(37% 0.013 285.805)','oklch(27.4% 0.006 286.033)','oklch(21% 0.006 285.885)','oklch(14.1% 0.005 285.823)'],
|
|
308
|
+
neutral: ['oklch(98.5% 0 0)','oklch(97% 0 0)','oklch(92.2% 0 0)','oklch(87% 0 0)','oklch(70.8% 0 0)','oklch(55.6% 0 0)','oklch(43.9% 0 0)','oklch(37.1% 0 0)','oklch(26.9% 0 0)','oklch(20.5% 0 0)','oklch(14.5% 0 0)'],
|
|
309
|
+
stone: ['oklch(98.5% 0.001 106.423)','oklch(97% 0.001 106.424)','oklch(92.3% 0.003 48.717)','oklch(86.9% 0.005 56.366)','oklch(70.9% 0.01 56.259)','oklch(55.3% 0.013 58.071)','oklch(44.4% 0.011 73.639)','oklch(37.4% 0.01 67.558)','oklch(26.8% 0.007 34.298)','oklch(21.6% 0.006 56.043)','oklch(14.7% 0.004 49.25)'],
|
|
310
|
+
mauve: ['oklch(98.5% 0 0)','oklch(96% 0.003 325.6)','oklch(92.2% 0.005 325.62)','oklch(86.5% 0.012 325.68)','oklch(71.1% 0.019 323.02)','oklch(54.2% 0.034 322.5)','oklch(43.5% 0.029 321.78)','oklch(36.4% 0.029 323.89)','oklch(26.3% 0.024 320.12)','oklch(21.2% 0.019 322.12)','oklch(14.5% 0.008 326)'],
|
|
311
|
+
olive: ['oklch(98.8% 0.003 106.5)','oklch(96.6% 0.005 106.5)','oklch(93% 0.007 106.5)','oklch(88% 0.011 106.6)','oklch(73.7% 0.021 106.9)','oklch(58% 0.031 107.3)','oklch(46.6% 0.025 107.3)','oklch(39.4% 0.023 107.4)','oklch(28.6% 0.016 107.4)','oklch(22.8% 0.013 107.4)','oklch(15.3% 0.006 107.1)'],
|
|
312
|
+
mist: ['oklch(98.7% 0.002 197.1)','oklch(96.3% 0.002 197.1)','oklch(92.5% 0.005 214.3)','oklch(87.2% 0.007 219.6)','oklch(72.3% 0.014 214.4)','oklch(56% 0.021 213.5)','oklch(45% 0.017 213.2)','oklch(37.8% 0.015 216)','oklch(27.5% 0.011 216.9)','oklch(21.8% 0.008 223.9)','oklch(14.8% 0.004 228.8)'],
|
|
313
|
+
taupe: ['oklch(98.6% 0.002 67.8)','oklch(96% 0.002 17.2)','oklch(92.2% 0.005 34.3)','oklch(86.8% 0.007 39.5)','oklch(71.4% 0.014 41.2)','oklch(54.7% 0.021 43.1)','oklch(43.8% 0.017 39.3)','oklch(36.7% 0.016 35.7)','oklch(26.8% 0.011 36.5)','oklch(21.4% 0.009 43.1)','oklch(14.7% 0.004 49.3)']
|
|
314
|
+
};
|
|
315
|
+
function titleCase(s) {
|
|
316
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
317
|
+
}
|
|
318
|
+
// Tailwind as a single group with one sub-palette per hue.
|
|
319
|
+
// `labels` (optional) localizes names: { _group, _base, _black, _white, red, orange, ... }.
|
|
320
|
+
// Missing labels fall back to English title-case.
|
|
321
|
+
function buildTailwindPreset(labels) {
|
|
322
|
+
const L = labels || {};
|
|
323
|
+
const groupName = L._group || 'Tailwind';
|
|
324
|
+
const sub = {};
|
|
325
|
+
for (const hue of TAILWIND_HUES) {
|
|
326
|
+
const shades = TAILWIND_COLORS[hue];
|
|
327
|
+
if (!shades) continue;
|
|
328
|
+
const paletteLabel = L[hue] || titleCase(hue);
|
|
329
|
+
sub[paletteLabel] = shades.map((value, i) => ({
|
|
330
|
+
name: paletteLabel + ' ' + TAILWIND_SHADES[i],
|
|
331
|
+
value
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
sub[L._base || 'Base'] = [
|
|
335
|
+
{ name: L._black || 'Black', value: '#000' },
|
|
336
|
+
{ name: L._white || 'White', value: '#fff' }
|
|
337
|
+
];
|
|
338
|
+
return { [groupName]: sub };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// iOS system colors (light + dark, exact values from Apple HIG, iOS 15+)
|
|
342
|
+
// Returns one group per color with the light/dark pair, plus a system-gray group.
|
|
343
|
+
const IOS_SYSTEM_COLORS = [
|
|
344
|
+
{ name: 'red', light: '#FF3B30', dark: '#FF453A' },
|
|
345
|
+
{ name: 'orange', light: '#FF9500', dark: '#FF9F0A' },
|
|
346
|
+
{ name: 'yellow', light: '#FFCC00', dark: '#FFD60A' },
|
|
347
|
+
{ name: 'green', light: '#34C759', dark: '#30D158' },
|
|
348
|
+
{ name: 'mint', light: '#00C7BE', dark: '#63E6E2' },
|
|
349
|
+
{ name: 'teal', light: '#30B0C7', dark: '#40CBE0' },
|
|
350
|
+
{ name: 'cyan', light: '#32ADE6', dark: '#64D2FF' },
|
|
351
|
+
{ name: 'blue', light: '#007AFF', dark: '#0A84FF' },
|
|
352
|
+
{ name: 'indigo', light: '#5856D6', dark: '#5E5CE6' },
|
|
353
|
+
{ name: 'purple', light: '#AF52DE', dark: '#BF5AF2' },
|
|
354
|
+
{ name: 'pink', light: '#FF2D55', dark: '#FF375F' },
|
|
355
|
+
{ name: 'brown', light: '#A2845E', dark: '#AC8E68' }
|
|
356
|
+
];
|
|
357
|
+
const IOS_SYSTEM_GRAYS = [
|
|
358
|
+
{ name: 'gray', light: '#8E8E93', dark: '#8E8E93' },
|
|
359
|
+
{ name: 'gray2', light: '#AEAEB2', dark: '#636366' },
|
|
360
|
+
{ name: 'gray3', light: '#C7C7CC', dark: '#48484A' },
|
|
361
|
+
{ name: 'gray4', light: '#D1D1D6', dark: '#3A3A3C' },
|
|
362
|
+
{ name: 'gray5', light: '#E5E5EA', dark: '#2C2C2E' },
|
|
363
|
+
{ name: 'gray6', light: '#F2F2F7', dark: '#1C1C1E' }
|
|
364
|
+
];
|
|
365
|
+
// iOS as a single group with 4 sub-palettes. `labels` localizes:
|
|
366
|
+
// { _group, _colorLight, _colorDark, _shadesLight, _shadesDark, red, orange, ..., gray, gray2, ... }
|
|
367
|
+
function buildIosPreset(labels) {
|
|
368
|
+
const L = labels || {};
|
|
369
|
+
const groupName = L._group || 'iOS';
|
|
370
|
+
// Swatch names are prefixed with the tone ("Light - " / "Dark - ") so users
|
|
371
|
+
// can still distinguish them by name once a color lands in Recent (where
|
|
372
|
+
// palette context is lost). The `_lightPrefix` / `_darkPrefix` meta keys
|
|
373
|
+
// let devs translate or remove the prefixes.
|
|
374
|
+
const lightPrefix = L._lightPrefix != null ? L._lightPrefix : 'Light - ';
|
|
375
|
+
const darkPrefix = L._darkPrefix != null ? L._darkPrefix : 'Dark - ';
|
|
376
|
+
const baseColorName = (c) => L[c.name] || titleCase(c.name);
|
|
377
|
+
const formatGray = (name) => {
|
|
378
|
+
if (L[name]) return L[name];
|
|
379
|
+
const m = name.match(/^(gray)(\d+)?$/);
|
|
380
|
+
return m ? (titleCase(m[1]) + (m[2] ? ' ' + m[2] : '')) : titleCase(name);
|
|
381
|
+
};
|
|
382
|
+
// Each tone gets one palette: system colors first, then grayscale shades.
|
|
383
|
+
const light = [
|
|
384
|
+
...IOS_SYSTEM_COLORS.map(c => ({ name: lightPrefix + baseColorName(c), value: c.light })),
|
|
385
|
+
...IOS_SYSTEM_GRAYS.map(g => ({ name: lightPrefix + formatGray(g.name), value: g.light }))
|
|
386
|
+
];
|
|
387
|
+
const dark = [
|
|
388
|
+
...IOS_SYSTEM_COLORS.map(c => ({ name: darkPrefix + baseColorName(c), value: c.dark })),
|
|
389
|
+
...IOS_SYSTEM_GRAYS.map(g => ({ name: darkPrefix + formatGray(g.name), value: g.dark }))
|
|
390
|
+
];
|
|
391
|
+
return {
|
|
392
|
+
[groupName]: {
|
|
393
|
+
[L._light || 'Light']: light,
|
|
394
|
+
[L._dark || 'Dark']: dark
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---- Library data normalization ----
|
|
400
|
+
|
|
401
|
+
// Normalize into: Group[] where each Group = { name?, colors?: Swatch[], palettes?: Palette[], ...extras }
|
|
402
|
+
// A Group has EITHER flat `colors` OR nested `palettes`, never both. Each Palette = { name?, colors: Swatch[], ...extras }.
|
|
403
|
+
// Swatch = { name?, value, ...extras }. Hierarchy is strictly Group > Palette > Swatch (no deeper nesting).
|
|
404
|
+
// Filter proxy/magic noise: drop $-prefixed keys (Manifest magic like $route, $x, $watch),
|
|
405
|
+
// symbol keys, function values, and the few inherited Object.prototype names that can leak
|
|
406
|
+
// through exotic proxies.
|
|
407
|
+
const _LIB_PROTO_NOISE = new Set(['valueOf', 'toString', 'constructor', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toLocaleString']);
|
|
408
|
+
function _cleanLibraryEntries(input) {
|
|
409
|
+
return Object.entries(input).filter(([k, v]) => {
|
|
410
|
+
if (typeof k !== 'string') return false;
|
|
411
|
+
if (k.startsWith('$')) return false; // Manifest magic ($route, $locale, $x, ...)
|
|
412
|
+
if (k.startsWith('_')) return false; // Manifest data metadata (_loadedFrom, _locale, _sourceType, ...)
|
|
413
|
+
if (k === 'contentType') return false; // Manifest fetched-resource metadata
|
|
414
|
+
if (_LIB_PROTO_NOISE.has(k)) return false;
|
|
415
|
+
if (v == null) return false;
|
|
416
|
+
if (typeof v === 'function') return false;
|
|
417
|
+
return true;
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function normalizeLibraryInput(input) {
|
|
422
|
+
if (input == null) return [];
|
|
423
|
+
if (Array.isArray(input)) {
|
|
424
|
+
const firstObjChild = input.find(x => x && typeof x === 'object' && !Array.isArray(x));
|
|
425
|
+
const looksLikeGroups = firstObjChild && ('colors' in firstObjChild || 'items' in firstObjChild || 'palettes' in firstObjChild || 'subgroups' in firstObjChild);
|
|
426
|
+
if (looksLikeGroups) return input.map(g => normalizeGroup(g, g.name));
|
|
427
|
+
return [{ colors: normalizeColorList(input) }];
|
|
428
|
+
}
|
|
429
|
+
if (typeof input === 'object') {
|
|
430
|
+
return _cleanLibraryEntries(input).map(([k, v]) => normalizeGroup(v, k));
|
|
431
|
+
}
|
|
432
|
+
return [];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function normalizeGroup(input, groupName) {
|
|
436
|
+
if (input == null) return { name: groupName, colors: [] };
|
|
437
|
+
|
|
438
|
+
// Explicit metadata form
|
|
439
|
+
if (!Array.isArray(input) && typeof input === 'object'
|
|
440
|
+
&& ('colors' in input || 'items' in input || 'palettes' in input || 'subgroups' in input)) {
|
|
441
|
+
const out = { ...input, name: input._group || input.name || groupName };
|
|
442
|
+
const palettesSrc = input.palettes || input.subgroups;
|
|
443
|
+
if (palettesSrc) {
|
|
444
|
+
out.palettes = (palettesSrc || []).map(p => normalizePalette(p, p?.name));
|
|
445
|
+
delete out.subgroups;
|
|
446
|
+
} else {
|
|
447
|
+
out.colors = normalizeColorList(input.colors || input.items || []);
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Array → flat colors list
|
|
453
|
+
if (Array.isArray(input)) {
|
|
454
|
+
return { name: groupName, colors: normalizeColorList(input) };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Object — `_group` meta overrides heading, nested values = palettes, string values = flat colors
|
|
458
|
+
if (typeof input === 'object') {
|
|
459
|
+
const resolvedName = (typeof input._group === 'string') ? input._group : groupName;
|
|
460
|
+
const entries = _cleanLibraryEntries(input);
|
|
461
|
+
const hasNestedValues = entries.some(([, v]) => v && typeof v === 'object');
|
|
462
|
+
if (hasNestedValues) {
|
|
463
|
+
return {
|
|
464
|
+
name: resolvedName,
|
|
465
|
+
palettes: entries.map(([k, v]) => normalizePalette(v, k))
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
return { name: resolvedName, colors: normalizeColorList(Object.fromEntries(entries)) };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return { name: groupName, colors: [] };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Merge built-in Recent + Tailwind + iOS into a single default data object.
|
|
475
|
+
// Used when `x-colorpicker.library` has no expression AND no ancestor $x data.
|
|
476
|
+
// Optional `labels` is forwarded to the Tailwind / iOS builders for localization.
|
|
477
|
+
function buildDefaultLibrary(labels) {
|
|
478
|
+
const L = labels || {};
|
|
479
|
+
return {
|
|
480
|
+
[L._recent || 'Recent']: _recentStore.list.slice(0, _recentMax),
|
|
481
|
+
...buildTailwindPreset(L.tailwind),
|
|
482
|
+
...buildIosPreset(L.ios)
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ---- Auto-discovery of colorpicker data sources ----
|
|
487
|
+
//
|
|
488
|
+
// Devs register data sources in manifest.json using the standard Manifest conventions,
|
|
489
|
+
// then flag which ones contain color library content via a top-level `colorpicker` entry:
|
|
490
|
+
//
|
|
491
|
+
// {
|
|
492
|
+
// "data": {
|
|
493
|
+
// "myColors": "/data/colors.yaml" // non-localized
|
|
494
|
+
// "brand": { "en": "/data/brand.en.yaml", "fr": "..." } // per-locale
|
|
495
|
+
// },
|
|
496
|
+
// "colorpicker": "myColors" // single source
|
|
497
|
+
// "colorpicker": ["myColors", "brand"] // multiple sources
|
|
498
|
+
// }
|
|
499
|
+
//
|
|
500
|
+
// The colorpicker plugin reads $x.manifest.colorpicker to know which $x.* sources to
|
|
501
|
+
// treat as library content, then merges them in listed order. Each source may contain:
|
|
502
|
+
// _tailwind: (optional) replaces the built-in Tailwind group entirely
|
|
503
|
+
// _ios: (optional) replaces the built-in iOS group entirely
|
|
504
|
+
// <Any Name>: custom groups appended to the library after built-ins
|
|
505
|
+
//
|
|
506
|
+
// Presence of _tailwind / _ios is "all or nothing" — no merge with built-in data.
|
|
507
|
+
// If multiple flagged sources both include _tailwind, the LAST source wins.
|
|
508
|
+
|
|
509
|
+
// Manifest's data proxy returns an empty object for ANY property access (graceful chain),
|
|
510
|
+
// so `src._tailwind` always looks truthy. We only treat an override as real if the source
|
|
511
|
+
// actually declared the key (`in` operator) AND the value has real content.
|
|
512
|
+
function _hasRealContent(v) {
|
|
513
|
+
if (v == null || typeof v !== 'object') return false;
|
|
514
|
+
return Object.keys(v).some(k => !k.startsWith('$')
|
|
515
|
+
&& k !== 'valueOf' && k !== 'toString'
|
|
516
|
+
&& k !== 'contentType');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Takes an array of resolved source values, extracts _tailwind / _ios overrides and
|
|
520
|
+
// custom groups from each, and returns the final normalized groups array.
|
|
521
|
+
function composeLibraryFromSources(sources) {
|
|
522
|
+
let tailwindOverride = null;
|
|
523
|
+
let iosOverride = null;
|
|
524
|
+
const customGroups = [];
|
|
525
|
+
|
|
526
|
+
for (const src of sources) {
|
|
527
|
+
if (src == null || typeof src !== 'object' || Array.isArray(src)) continue;
|
|
528
|
+
|
|
529
|
+
// Extract override blocks only if actually declared in source AND have real content.
|
|
530
|
+
if ('_tailwind' in src && _hasRealContent(src._tailwind)) tailwindOverride = src._tailwind;
|
|
531
|
+
if ('_ios' in src && _hasRealContent(src._ios)) iosOverride = src._ios;
|
|
532
|
+
|
|
533
|
+
// All other (non-reserved, non-metadata) keys become custom groups
|
|
534
|
+
for (const [k, v] of _cleanLibraryEntries(src)) {
|
|
535
|
+
if (k === '_tailwind' || k === '_ios') continue;
|
|
536
|
+
if (!_hasRealContent(v)) continue;
|
|
537
|
+
customGroups.push(normalizeGroup(v, k));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Assemble final group order: Recent → custom → Tailwind → iOS.
|
|
542
|
+
// Recent may be empty (pre-use state) — we render the group only when it has content.
|
|
543
|
+
const recent = _recentStore.list.slice(0, _recentMax);
|
|
544
|
+
const groups = [];
|
|
545
|
+
if (recent.length) {
|
|
546
|
+
const recentGroup = normalizeGroup(recent, 'Recent');
|
|
547
|
+
recentGroup._isRecent = true; // marker for contextmenu binding
|
|
548
|
+
groups.push(recentGroup);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
groups.push(...customGroups);
|
|
552
|
+
|
|
553
|
+
if (tailwindOverride) {
|
|
554
|
+
// `_group` meta inside the override becomes the group heading; default to "Tailwind"
|
|
555
|
+
groups.push(normalizeGroup(tailwindOverride, tailwindOverride._group || 'Tailwind'));
|
|
556
|
+
} else {
|
|
557
|
+
groups.push(...normalizeLibraryInput(buildTailwindPreset()));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (iosOverride) {
|
|
561
|
+
groups.push(normalizeGroup(iosOverride, iosOverride._group || 'iOS'));
|
|
562
|
+
} else {
|
|
563
|
+
groups.push(...normalizeLibraryInput(buildIosPreset()));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return groups;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Returns an object that is BOTH callable (for localization) AND spreadable (for composition).
|
|
570
|
+
// `builder` is a function that takes optional labels and returns a preset object.
|
|
571
|
+
// Spreading the returned value exposes the default (unlabeled) preset's top-level keys.
|
|
572
|
+
function _makeCallablePreset(builder) {
|
|
573
|
+
const fn = (labels) => builder(labels);
|
|
574
|
+
Object.assign(fn, builder()); // default preset's keys become own enumerable props on fn
|
|
575
|
+
return fn;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Render a group by cloning the group template and expanding nested templates in place.
|
|
579
|
+
// Returns a fragment with all top-level elements (scope applied to the first). `isRecent`
|
|
580
|
+
// threads down so palette/swatch layers can pick the Recent-specific swatch template.
|
|
581
|
+
function renderLibraryGroup(groupTpl, group) {
|
|
582
|
+
const frag = groupTpl.content.cloneNode(true);
|
|
583
|
+
const primary = frag.firstElementChild;
|
|
584
|
+
if (!primary) return frag;
|
|
585
|
+
primary.setAttribute('x-data', '{ group: ' + _jsonStringifyForAlpine(group) + ' }');
|
|
586
|
+
|
|
587
|
+
const isRecent = !!group._isRecent;
|
|
588
|
+
|
|
589
|
+
// Normalize: flat groups become single unnamed palette so templates work uniformly
|
|
590
|
+
const palettes = (group.palettes && group.palettes.length)
|
|
591
|
+
? group.palettes
|
|
592
|
+
: (group.colors && group.colors.length ? [{ name: null, colors: group.colors }] : []);
|
|
593
|
+
|
|
594
|
+
const paletteTpl = frag.querySelector('template[x-colorpicker\\.library-palette]');
|
|
595
|
+
if (paletteTpl) {
|
|
596
|
+
const parent = paletteTpl.parentNode;
|
|
597
|
+
for (const p of palettes) parent.insertBefore(renderLibraryPalette(paletteTpl, p, isRecent), paletteTpl);
|
|
598
|
+
paletteTpl.remove();
|
|
599
|
+
} else {
|
|
600
|
+
// No palette template — look for a swatch template directly inside the group.
|
|
601
|
+
// Prefer the Recent-specific template for Recent groups, else fall back.
|
|
602
|
+
const swatchTpl = (isRecent ? frag.querySelector('template[x-colorpicker\\.library-recent-swatch]') : null)
|
|
603
|
+
|| frag.querySelector('template[x-colorpicker\\.library-swatch]');
|
|
604
|
+
if (swatchTpl) {
|
|
605
|
+
const parent = swatchTpl.parentNode;
|
|
606
|
+
const allColors = palettes.flatMap(p => p.colors || []);
|
|
607
|
+
for (const sw of allColors) parent.insertBefore(renderLibrarySwatch(swatchTpl, sw), swatchTpl);
|
|
608
|
+
swatchTpl.remove();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return frag;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function renderLibraryPalette(paletteTpl, palette, isRecent) {
|
|
615
|
+
const frag = paletteTpl.content.cloneNode(true);
|
|
616
|
+
const primary = frag.firstElementChild;
|
|
617
|
+
if (!primary) return frag;
|
|
618
|
+
primary.setAttribute('x-data', '{ palette: ' + _jsonStringifyForAlpine(palette) + ' }');
|
|
619
|
+
|
|
620
|
+
// Recent palettes prefer library-recent-swatch template if defined.
|
|
621
|
+
const swatchTpl = (isRecent ? frag.querySelector('template[x-colorpicker\\.library-recent-swatch]') : null)
|
|
622
|
+
|| frag.querySelector('template[x-colorpicker\\.library-swatch]');
|
|
623
|
+
if (swatchTpl) {
|
|
624
|
+
const parent = swatchTpl.parentNode;
|
|
625
|
+
for (const sw of (palette.colors || [])) parent.insertBefore(renderLibrarySwatch(swatchTpl, sw), swatchTpl);
|
|
626
|
+
swatchTpl.remove();
|
|
627
|
+
}
|
|
628
|
+
return frag;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Per-clone counter for uniquifying nested dropdown/menu ids — mirrors the scheme
|
|
632
|
+
// used for gradient layer clones. Only menus that live INSIDE the swatch template
|
|
633
|
+
// get uniquified; menus placed at the library root are left alone (shared).
|
|
634
|
+
let _swatchCloneCounter = 0;
|
|
635
|
+
|
|
636
|
+
function renderLibrarySwatch(swatchTpl, swatch) {
|
|
637
|
+
const frag = swatchTpl.content.cloneNode(true);
|
|
638
|
+
const primary = frag.firstElementChild;
|
|
639
|
+
if (!primary) return frag;
|
|
640
|
+
// Apply the swatch scope to the primary element (typically the swatch button).
|
|
641
|
+
// Sibling elements like nested menus don't need swatch scope — their actions
|
|
642
|
+
// read from the dropdowns plugin's trigger ref.
|
|
643
|
+
primary.setAttribute('x-data', '{ swatch: ' + _jsonStringifyForAlpine(swatch) + ' }');
|
|
644
|
+
// Raw value + canonical key go on BOTH the actual apply-color element AND the
|
|
645
|
+
// primary wrapper (if different). That way `_updateActiveSwatches` can toggle
|
|
646
|
+
// `.active` on both, and dev CSS targeting either selector — the wrapper div
|
|
647
|
+
// (for layout effects like `order: -1`) or the button (for box-shadow) — works.
|
|
648
|
+
if (swatch && typeof swatch.value === 'string') {
|
|
649
|
+
const applyEl = frag.querySelector('[x-colorpicker\\.apply-color]');
|
|
650
|
+
const key = _swatchKeyOf(swatch.value);
|
|
651
|
+
const targets = new Set();
|
|
652
|
+
if (primary) targets.add(primary);
|
|
653
|
+
if (applyEl) targets.add(applyEl);
|
|
654
|
+
for (const t of targets) {
|
|
655
|
+
t.setAttribute('data-cp-value', swatch.value);
|
|
656
|
+
if (key) t.setAttribute('data-cp-key', key);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// Uniquify any nested dropdown/menu ids so per-swatch context menus don't
|
|
660
|
+
// collide. Skipped automatically when the menu lives outside the template.
|
|
661
|
+
uniquifyDropdownIdsIn(frag, 'swatch-' + (++_swatchCloneCounter));
|
|
662
|
+
return frag;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Default fallback: if no library template supplied, render a small heading per group
|
|
666
|
+
// and a flex-wrap row of apply-color spans for each swatch.
|
|
667
|
+
function renderDefaultGroup(group) {
|
|
668
|
+
const root = document.createElement('div');
|
|
669
|
+
root.setAttribute('data-cp-library-group', group.name || '');
|
|
670
|
+
if (group.name) {
|
|
671
|
+
const h = document.createElement('small');
|
|
672
|
+
h.textContent = group.name;
|
|
673
|
+
root.appendChild(h);
|
|
674
|
+
}
|
|
675
|
+
const palettes = (group.palettes && group.palettes.length)
|
|
676
|
+
? group.palettes
|
|
677
|
+
: (group.colors && group.colors.length ? [{ name: null, colors: group.colors }] : []);
|
|
678
|
+
for (const p of palettes) {
|
|
679
|
+
if (p.name) {
|
|
680
|
+
const ph = document.createElement('small');
|
|
681
|
+
ph.textContent = p.name;
|
|
682
|
+
root.appendChild(ph);
|
|
683
|
+
}
|
|
684
|
+
const row = document.createElement('div');
|
|
685
|
+
row.className = 'swatches';
|
|
686
|
+
for (const sw of (p.colors || [])) {
|
|
687
|
+
const span = document.createElement('span');
|
|
688
|
+
span.setAttribute('x-colorpicker.apply-color', '');
|
|
689
|
+
span.style.background = sw.value;
|
|
690
|
+
span.title = sw.name || sw.value;
|
|
691
|
+
row.appendChild(span);
|
|
692
|
+
}
|
|
693
|
+
root.appendChild(row);
|
|
694
|
+
}
|
|
695
|
+
return root;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function normalizePalette(input, paletteName) {
|
|
699
|
+
if (input == null) return { name: paletteName, colors: [] };
|
|
700
|
+
if (Array.isArray(input)) return { name: paletteName, colors: normalizeColorList(input) };
|
|
701
|
+
if (typeof input === 'object') {
|
|
702
|
+
// Meta key `_name` overrides the display name (lets devs keep object keys stable
|
|
703
|
+
// across locales while translating visible text).
|
|
704
|
+
const displayName = (typeof input._name === 'string') ? input._name : (input.name || paletteName);
|
|
705
|
+
// Explicit `_colors` / `colors` / `items` key holds the swatch list — use when
|
|
706
|
+
// you need to pair `_name` with an array of bare hex strings (YAML can't mix
|
|
707
|
+
// object keys and sequence items in the same node).
|
|
708
|
+
if ('_colors' in input || 'colors' in input || 'items' in input) {
|
|
709
|
+
return {
|
|
710
|
+
...input,
|
|
711
|
+
name: displayName,
|
|
712
|
+
colors: normalizeColorList(input._colors || input.colors || input.items || [])
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
return { name: displayName, colors: normalizeColorList(input) };
|
|
716
|
+
}
|
|
717
|
+
return { name: paletteName, colors: [] };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Accepts swatch forms:
|
|
721
|
+
// "#hex" → { value }
|
|
722
|
+
// { name, value } → plain
|
|
723
|
+
// { _name, _value } → meta-key form (localization-friendly)
|
|
724
|
+
function _coerceSwatch(item, fallbackName) {
|
|
725
|
+
if (item == null) return null;
|
|
726
|
+
if (typeof item === 'string') return fallbackName ? { name: fallbackName, value: item } : { value: item };
|
|
727
|
+
if (typeof item === 'object') {
|
|
728
|
+
const name = (typeof item._name === 'string') ? item._name : (item.name || fallbackName);
|
|
729
|
+
const value = (typeof item._value === 'string') ? item._value : item.value;
|
|
730
|
+
if (value == null) return null;
|
|
731
|
+
return name ? { name, value } : { value };
|
|
732
|
+
}
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function normalizeColorList(input) {
|
|
737
|
+
if (input == null) return [];
|
|
738
|
+
if (Array.isArray(input)) {
|
|
739
|
+
return input.map(item => _coerceSwatch(item)).filter(Boolean);
|
|
740
|
+
}
|
|
741
|
+
if (typeof input === 'object') {
|
|
742
|
+
// Filter `_`-prefixed meta keys (e.g., `_name`, `_group`) so they're not mistaken for shades.
|
|
743
|
+
return Object.entries(input)
|
|
744
|
+
.filter(([k]) => !k.startsWith('_'))
|
|
745
|
+
.map(([name, value]) => _coerceSwatch(value, name))
|
|
746
|
+
.filter(Boolean);
|
|
747
|
+
}
|
|
748
|
+
return [];
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ---- Recent (cookie-backed, shared across all pickers) ----
|
|
752
|
+
|
|
753
|
+
const RECENT_COOKIE = 'manifest-colorpicker-recent';
|
|
754
|
+
let _recentMax = 10;
|
|
755
|
+
const _recentStore = window.Alpine?.reactive ? Alpine.reactive({ list: [] }) : { list: [] };
|
|
756
|
+
|
|
757
|
+
function loadRecent() {
|
|
758
|
+
try {
|
|
759
|
+
const match = document.cookie.split('; ').find(c => c.startsWith(RECENT_COOKIE + '='));
|
|
760
|
+
if (!match) return [];
|
|
761
|
+
const raw = decodeURIComponent(match.split('=')[1] || '');
|
|
762
|
+
const parsed = JSON.parse(raw);
|
|
763
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
764
|
+
} catch { return []; }
|
|
765
|
+
}
|
|
766
|
+
function saveRecent() {
|
|
767
|
+
try {
|
|
768
|
+
const val = encodeURIComponent(JSON.stringify(_recentStore.list));
|
|
769
|
+
const exp = new Date(); exp.setFullYear(exp.getFullYear() + 1);
|
|
770
|
+
document.cookie = RECENT_COOKIE + '=' + val + '; path=/; expires=' + exp.toUTCString() + '; SameSite=Lax';
|
|
771
|
+
} catch {}
|
|
772
|
+
}
|
|
773
|
+
function pushRecent(value) {
|
|
774
|
+
if (!value || typeof value !== 'string') return;
|
|
775
|
+
const v = value.trim();
|
|
776
|
+
if (!v) return;
|
|
777
|
+
const list = _recentStore.list;
|
|
778
|
+
const existingIdx = list.indexOf(v);
|
|
779
|
+
if (existingIdx !== -1) list.splice(existingIdx, 1);
|
|
780
|
+
list.unshift(v);
|
|
781
|
+
while (list.length > _recentMax) list.pop();
|
|
782
|
+
saveRecent();
|
|
783
|
+
}
|
|
784
|
+
function clearRecent() { _recentStore.list = []; saveRecent(); }
|
|
785
|
+
function removeRecent(value) {
|
|
786
|
+
const idx = _recentStore.list.indexOf(value);
|
|
787
|
+
if (idx !== -1) { _recentStore.list.splice(idx, 1); saveRecent(); }
|
|
788
|
+
}
|
|
789
|
+
// Initial load from cookie
|
|
790
|
+
_recentStore.list = loadRecent();
|
|
791
|
+
|
|
792
|
+
// ---- Default fallback templates (used when developer doesn't supply their own) ----
|
|
793
|
+
|
|
794
|
+
// Solid-options panel — used both as a top-level tab body and as the
|
|
795
|
+
// accordion content under each gradient stop.
|
|
796
|
+
const DEFAULT_SOLID_TEMPLATE_HTML = `
|
|
797
|
+
<div>
|
|
798
|
+
<div class="canvas-wrapper">
|
|
799
|
+
<canvas x-colorpicker.set-canvas></canvas>
|
|
800
|
+
<div class="color-reticle"></div>
|
|
801
|
+
</div>
|
|
802
|
+
<div class="solid-options-inputs">
|
|
803
|
+
<input type="range" x-colorpicker.set-hue class="hue" />
|
|
804
|
+
<input type="range" x-colorpicker.set-alpha class="alpha" />
|
|
805
|
+
<div>
|
|
806
|
+
<button x-dropdown="color-space-menu" x-colorpicker.set-color-space class="ghost sm" aria-label="Color space"></button>
|
|
807
|
+
<menu popover id="color-space-menu">
|
|
808
|
+
<li x-colorpicker.set-color-space="hex">Hex</li>
|
|
809
|
+
<li x-colorpicker.set-color-space="rgb">RGB</li>
|
|
810
|
+
<li x-colorpicker.set-color-space="hsl">HSL</li>
|
|
811
|
+
<li x-colorpicker.set-color-space="oklch">OKLCH</li>
|
|
812
|
+
<hr>
|
|
813
|
+
<li x-colorpicker.grab-color><span x-icon class="color-icon-grab"></span><span>Grab color</span></li>
|
|
814
|
+
</menu>
|
|
815
|
+
<input type="text" x-colorpicker.set-color-value class="ghost sm" onClick="this.select()" />
|
|
816
|
+
<input type="number" x-colorpicker.set-alpha-value class="ghost sm no-spinner" min="0" max="100" step="1" onClick="this.select()" />
|
|
817
|
+
</div>
|
|
818
|
+
</div>
|
|
819
|
+
</div>
|
|
820
|
+
`;
|
|
821
|
+
|
|
822
|
+
// Gradient layer template — one of these per layer clone. Includes the
|
|
823
|
+
// gradient-type dropdown (with reactive icon class), angle input, stops bar
|
|
824
|
+
// with right-click context menu (Duplicate/Delete + inline library), and
|
|
825
|
+
// the per-stop solid-panel accordion.
|
|
826
|
+
const DEFAULT_LAYER_TEMPLATE_HTML = `
|
|
827
|
+
<div>
|
|
828
|
+
<div class="layer-options-wrapper">
|
|
829
|
+
<div class="layer-options-inputs">
|
|
830
|
+
<button class="ghost sm" x-dropdown="gradient-layer-options" aria-label="Layer options"><span :class="'gradient-layer-icon-' + layerType" x-icon></span></button>
|
|
831
|
+
<menu popover id="gradient-layer-options">
|
|
832
|
+
<li x-colorpicker.set-gradient-type="linear">
|
|
833
|
+
<span x-icon class="gradient-layer-icon-linear"></span><span>Linear Gradient</span>
|
|
834
|
+
</li>
|
|
835
|
+
<li x-colorpicker.set-gradient-type="radial">
|
|
836
|
+
<span x-icon class="gradient-layer-icon-radial"></span><span>Radial Gradient</span>
|
|
837
|
+
</li>
|
|
838
|
+
<li x-colorpicker.set-gradient-type="conic">
|
|
839
|
+
<span x-icon class="gradient-layer-icon-conic"></span><span>Conic Gradient</span>
|
|
840
|
+
</li>
|
|
841
|
+
<hr>
|
|
842
|
+
<li x-colorpicker.rotate-layer>Rotate 90°</li>
|
|
843
|
+
<li x-colorpicker.flip-layer>Flip Direction</li>
|
|
844
|
+
<hr>
|
|
845
|
+
<li x-colorpicker.add-layer-above>Add Above</li>
|
|
846
|
+
<li x-colorpicker.add-layer-below>Add Below</li>
|
|
847
|
+
<li x-colorpicker.duplicate-layer>Duplicate</li>
|
|
848
|
+
<hr>
|
|
849
|
+
<li x-colorpicker.move-layer-up :disabled="layerIndex === 0">Move Up</li>
|
|
850
|
+
<li x-colorpicker.move-layer-down :disabled="layerIndex === layerCount - 1">Move Down</li>
|
|
851
|
+
<hr>
|
|
852
|
+
<li x-colorpicker.remove-layer :disabled="layerCount === 1" class="negative">Remove</li>
|
|
853
|
+
</menu>
|
|
854
|
+
|
|
855
|
+
<div class="layer-angle-wrapper">
|
|
856
|
+
<input type="number" x-colorpicker.set-angle class="ghost sm no-spinner" min="0" max="360" onclick="select()" />
|
|
857
|
+
<span>°</span>
|
|
858
|
+
</div>
|
|
859
|
+
|
|
860
|
+
<div x-colorpicker.layer-stops-bar class="gradient-layer" x-dropdown.context="stop-context-menu"></div>
|
|
861
|
+
<menu popover id="stop-context-menu" class="stop-context-menu">
|
|
862
|
+
<li x-colorpicker.duplicate-stop>Duplicate</li>
|
|
863
|
+
<li x-colorpicker.delete-stop class="negative">Delete</li>
|
|
864
|
+
<hr>
|
|
865
|
+
<div x-colorpicker.library></div>
|
|
866
|
+
</menu>
|
|
867
|
+
</div>
|
|
868
|
+
|
|
869
|
+
<div x-colorpicker.solid></div>
|
|
870
|
+
</div>
|
|
871
|
+
</div>
|
|
872
|
+
`;
|
|
873
|
+
|
|
874
|
+
// Gradient panel — the entire Gradient tab body. Hosts the layer template
|
|
875
|
+
// (cloned per layer), the layers container, and the editable CSS string.
|
|
876
|
+
const DEFAULT_GRADIENT_TEMPLATE_HTML = `
|
|
877
|
+
<div>
|
|
878
|
+
<div x-colorpicker.gradient-layers></div>
|
|
879
|
+
<template x-colorpicker.layer-options>
|
|
880
|
+
${DEFAULT_LAYER_TEMPLATE_HTML}
|
|
881
|
+
</template>
|
|
882
|
+
<div class="gradient-value-wrapper">
|
|
883
|
+
<textarea x-colorpicker.set-gradient-value class="ghost sm" spellcheck="false" rows="3" onclick="select()"></textarea>
|
|
884
|
+
</div>
|
|
885
|
+
</div>
|
|
886
|
+
`;
|
|
887
|
+
|
|
888
|
+
// Full default UI — used when an x-colorpicker has no children and no
|
|
889
|
+
// declared templates. Mirrors the canonical custom-picker structure:
|
|
890
|
+
// tabs row, three tab bodies, three matching templates.
|
|
891
|
+
const DEFAULT_FULL_UI_HTML = `
|
|
892
|
+
<div x-data="{ tab: 'solid' }">
|
|
893
|
+
|
|
894
|
+
<div class="tabs-wrapper" data-cp-tabs>
|
|
895
|
+
<button data-cp-tab="solid" class="ghost sm" :class="tab === 'solid' && 'selected'" @click="tab = 'solid'" x-tooltip="Solid" aria-label="Solid"><span x-icon class="color-icon-solid"></span></button>
|
|
896
|
+
<button data-cp-tab="gradient" class="ghost sm" :class="tab === 'gradient' && 'selected'" @click="tab = 'gradient'" x-tooltip="Gradient" aria-label="Gradient"><span x-icon class="color-icon-gradient"></span></button>
|
|
897
|
+
<button data-cp-tab="library" class="ghost sm" :class="tab === 'library' && 'selected'" @click="tab = 'library'" x-tooltip="Library" aria-label="Library"><span x-icon class="color-icon-library"></span></button>
|
|
898
|
+
</div>
|
|
899
|
+
|
|
900
|
+
<div data-cp-panel="solid" x-colorpicker.solid x-show="tab === 'solid'"></div>
|
|
901
|
+
<div data-cp-panel="gradient" x-colorpicker.gradient x-show="tab === 'gradient'"></div>
|
|
902
|
+
<div data-cp-panel="library" x-colorpicker.library x-show="tab === 'library'"></div>
|
|
903
|
+
|
|
904
|
+
<template x-colorpicker.solid>
|
|
905
|
+
${DEFAULT_SOLID_TEMPLATE_HTML}
|
|
906
|
+
</template>
|
|
907
|
+
|
|
908
|
+
<template x-colorpicker.gradient>
|
|
909
|
+
${DEFAULT_GRADIENT_TEMPLATE_HTML}
|
|
910
|
+
</template>
|
|
911
|
+
</div>
|
|
912
|
+
`;
|
|
913
|
+
|
|
914
|
+
// Parse default template HTML ONCE at module load. Subsequent uses clone the
|
|
915
|
+
// already-parsed DocumentFragment — avoids re-running the HTML parser per layer.
|
|
916
|
+
function parseOnce(html) {
|
|
917
|
+
const t = document.createElement('template');
|
|
918
|
+
t.innerHTML = html.trim();
|
|
919
|
+
return t;
|
|
920
|
+
}
|
|
921
|
+
const _defaultSolidTpl = parseOnce(DEFAULT_SOLID_TEMPLATE_HTML);
|
|
922
|
+
const _defaultLayerTpl = parseOnce(DEFAULT_LAYER_TEMPLATE_HTML);
|
|
923
|
+
const _defaultGradientTpl = parseOnce(DEFAULT_GRADIENT_TEMPLATE_HTML);
|
|
924
|
+
const _defaultFullUiTpl = parseOnce(DEFAULT_FULL_UI_HTML);
|
|
925
|
+
|
|
926
|
+
// Default library layout — group > palette > swatch nesting. Mirrors the
|
|
927
|
+
// canonical custom-picker library template. Used when no dev-provided
|
|
928
|
+
// <template x-colorpicker.library> is present.
|
|
929
|
+
// Recent swatches use the per-clone nested context-menu pattern (each
|
|
930
|
+
// Recent swatch carries its own uniquified menu via uniquifyDropdownIdsIn,
|
|
931
|
+
// matching the convention for layer dropdowns).
|
|
932
|
+
const DEFAULT_LIBRARY_LAYOUT_HTML = `
|
|
933
|
+
<div class="library-wrapper">
|
|
934
|
+
|
|
935
|
+
<template x-colorpicker.library-group>
|
|
936
|
+
<div class="library-group">
|
|
937
|
+
<small x-text="group.name"></small>
|
|
938
|
+
|
|
939
|
+
<template x-colorpicker.library-palette>
|
|
940
|
+
<div class="library-palette">
|
|
941
|
+
|
|
942
|
+
<template x-colorpicker.library-swatch>
|
|
943
|
+
<button x-colorpicker.apply-color :style="\`background: \${swatch.value}\`" x-tooltip="\`\${swatch.name || swatch.value}\`"></button>
|
|
944
|
+
</template>
|
|
945
|
+
|
|
946
|
+
<template x-colorpicker.library-recent-swatch>
|
|
947
|
+
<div>
|
|
948
|
+
<button x-colorpicker.apply-color x-dropdown.context="recent-menu" :style="\`background: \${swatch.value}\`" x-tooltip="\`\${swatch.name || swatch.value}\`"></button>
|
|
949
|
+
<menu popover id="recent-menu">
|
|
950
|
+
<li x-colorpicker.remove-recent>Remove</li>
|
|
951
|
+
</menu>
|
|
952
|
+
</div>
|
|
953
|
+
</template>
|
|
954
|
+
|
|
955
|
+
</div>
|
|
956
|
+
</template>
|
|
957
|
+
</div>
|
|
958
|
+
</template>
|
|
959
|
+
|
|
960
|
+
</div>
|
|
961
|
+
`;
|
|
962
|
+
const _defaultLibraryLayoutTpl = parseOnce(DEFAULT_LIBRARY_LAYOUT_HTML);
|
|
963
|
+
|
|
964
|
+
// ---- Per-picker state ----
|
|
965
|
+
|
|
966
|
+
let pickerCounter = 0;
|
|
967
|
+
|
|
968
|
+
// Reactive registry of pickers keyed by element ID.
|
|
969
|
+
// Consumers read via the magic; reads are tracked even for not-yet-registered IDs,
|
|
970
|
+
// so bindings resolve correctly when a picker mounts later in the DOM.
|
|
971
|
+
const _pickerRegistry = window.Alpine?.reactive ? Alpine.reactive({}) : {};
|
|
972
|
+
|
|
973
|
+
// Fallback API used when a picker ID isn't registered yet; coerces to empty string.
|
|
974
|
+
const _nullApi = (() => {
|
|
975
|
+
const noop = () => {};
|
|
976
|
+
const empty = () => '';
|
|
977
|
+
return {
|
|
978
|
+
hex: '', formatted: '', css: '',
|
|
979
|
+
h: 0, s: 0, v: 100, a: 1,
|
|
980
|
+
format: 'hex', pickerMode: 'solid',
|
|
981
|
+
layers: [], activeLayer: null, activeStop: null,
|
|
982
|
+
activeLayerIndex: 0, activeStopIndex: 0, openStop: null,
|
|
983
|
+
[Symbol.toPrimitive]: empty, toString: empty, valueOf: empty,
|
|
984
|
+
addLayer: noop, duplicateLayer: noop, removeLayer: noop,
|
|
985
|
+
flipLayer: noop, rotateLayer: noop,
|
|
986
|
+
addStop: noop, duplicateStop: noop, deleteStop: noop,
|
|
987
|
+
setGradientType: noop, applyColor: noop, grabColor: noop,
|
|
988
|
+
setHue: noop, setAlpha: noop, setAlphaValue: noop,
|
|
989
|
+
setColorSpace: noop, setColorValue: noop, setAngle: noop, setGradientValue: noop,
|
|
990
|
+
selectStop: noop, toggleStop: noop,
|
|
991
|
+
setFromString: () => false, toFormattedString: empty, toHex: empty
|
|
992
|
+
};
|
|
993
|
+
})();
|
|
994
|
+
|
|
995
|
+
function createPickerState(rootEl) {
|
|
996
|
+
pickerCounter++;
|
|
997
|
+
const pickerUid = 'cp-' + pickerCounter;
|
|
998
|
+
|
|
999
|
+
const state = {
|
|
1000
|
+
rootEl,
|
|
1001
|
+
pickerUid,
|
|
1002
|
+
|
|
1003
|
+
// Data
|
|
1004
|
+
solidColor: { h: 0, s: 0, v: 100, a: 1 },
|
|
1005
|
+
solidFormat: 'hex',
|
|
1006
|
+
layers: [ makeDefaultLayer() ],
|
|
1007
|
+
activeLayerIndex: 0,
|
|
1008
|
+
activeStopIndex: 0,
|
|
1009
|
+
pickerMode: 'solid',
|
|
1010
|
+
openStop: null, // { layerIndex, stopIndex } | null
|
|
1011
|
+
|
|
1012
|
+
// Tab/panel filtering. When set (via array-literal directive expression
|
|
1013
|
+
// on the root, e.g. `x-colorpicker="['solid', 'gradient']"`), the default
|
|
1014
|
+
// injected UI is filtered + reordered to match. null/empty = all panels.
|
|
1015
|
+
allowedPanels: null,
|
|
1016
|
+
|
|
1017
|
+
// Elements that act as live labels for the current solid format (e.g. a
|
|
1018
|
+
// dropdown button whose text reads "Hex"/"RGB"/"HSL"/"OKLCH"). Refreshed
|
|
1019
|
+
// whenever the format changes via _refreshFormatLabels().
|
|
1020
|
+
formatLabelEls: [],
|
|
1021
|
+
// Elements bound to a specific format value (e.g. <li set-color-space="hex">).
|
|
1022
|
+
// Refreshed alongside the labels to toggle an `active` class on the current.
|
|
1023
|
+
formatChoiceEls: [],
|
|
1024
|
+
|
|
1025
|
+
// Element registry (populated by child directive handlers)
|
|
1026
|
+
hiddenInput: null,
|
|
1027
|
+
triggerBtn: null,
|
|
1028
|
+
solidTemplate: null, // <template x-colorpicker.solid>
|
|
1029
|
+
gradientTemplate: null, // <template x-colorpicker.gradient>
|
|
1030
|
+
layerTemplate: null, // <template x-colorpicker.layer-options>
|
|
1031
|
+
solidInstances: [], // <div x-colorpicker.solid> (not template)
|
|
1032
|
+
gradientInstances: [], // <div x-colorpicker.gradient> (not template)
|
|
1033
|
+
layersContainer: null, // <div x-colorpicker.gradient-layers>
|
|
1034
|
+
gradientValueInputs: [], // <textarea x-colorpicker.set-gradient-value>
|
|
1035
|
+
|
|
1036
|
+
// Library
|
|
1037
|
+
libraryContainers: [], // <div x-colorpicker.library> — every registered target (top-level tab, nested stop menus, etc.)
|
|
1038
|
+
libraryTemplate: null, // <template x-colorpicker.library> — dev layout (cloned into container)
|
|
1039
|
+
libraryRootValue: null, // expression from x-colorpicker.library="..." (data source)
|
|
1040
|
+
|
|
1041
|
+
// Recent-list commit tracking. `_recentBaseline` is the color at the start of
|
|
1042
|
+
// a commit cycle (popover open, or inline picker init / last commit).
|
|
1043
|
+
// `_lastChangeFromLibrary` is true if the most recent user change was picking
|
|
1044
|
+
// a preset swatch — those don't count as "recent" even if committed.
|
|
1045
|
+
// `_hasUserChange` is true if any non-library user interaction has happened
|
|
1046
|
+
// since the baseline was set.
|
|
1047
|
+
_recentBaseline: null,
|
|
1048
|
+
_lastChangeFromLibrary: false,
|
|
1049
|
+
_hasUserChange: false,
|
|
1050
|
+
|
|
1051
|
+
// The currently active solid-controls refs (inside Solid tab OR open-stop accordion)
|
|
1052
|
+
activeControls: null,
|
|
1053
|
+
solidTabRefs: null,
|
|
1054
|
+
|
|
1055
|
+
// Reactive version counter — bumped on every state change. The API
|
|
1056
|
+
// getters read this to establish a reactive dependency, then compute
|
|
1057
|
+
// values lazily. No upfront work when nothing is bound to $colorpicker(id).
|
|
1058
|
+
snapshot: window.Alpine?.reactive ? Alpine.reactive({ version: 0 }) : { version: 0 },
|
|
1059
|
+
|
|
1060
|
+
// ---- Active target accessors ----
|
|
1061
|
+
|
|
1062
|
+
get isGradient() { return this.pickerMode === 'gradient'; },
|
|
1063
|
+
activeLayer() { return this.layers[this.activeLayerIndex] || this.layers[0]; },
|
|
1064
|
+
activeStop() {
|
|
1065
|
+
const layer = this.activeLayer();
|
|
1066
|
+
return layer.stops[this.activeStopIndex] || layer.stops[0];
|
|
1067
|
+
},
|
|
1068
|
+
|
|
1069
|
+
get h() { return this.isGradient ? this.activeStop().color.h : this.solidColor.h; },
|
|
1070
|
+
set h(v) { if (this.isGradient) this.activeStop().color.h = v; else this.solidColor.h = v; },
|
|
1071
|
+
get s() { return this.isGradient ? this.activeStop().color.s : this.solidColor.s; },
|
|
1072
|
+
set s(v) { if (this.isGradient) this.activeStop().color.s = v; else this.solidColor.s = v; },
|
|
1073
|
+
get v() { return this.isGradient ? this.activeStop().color.v : this.solidColor.v; },
|
|
1074
|
+
set v(val) { if (this.isGradient) this.activeStop().color.v = val; else this.solidColor.v = val; },
|
|
1075
|
+
get a() { return this.isGradient ? this.activeStop().color.a : this.solidColor.a; },
|
|
1076
|
+
set a(v) { if (this.isGradient) this.activeStop().color.a = v; else this.solidColor.a = v; },
|
|
1077
|
+
|
|
1078
|
+
get format() { return this.isGradient ? (this.activeStop().format || 'hex') : this.solidFormat; },
|
|
1079
|
+
set format(v) {
|
|
1080
|
+
if (this.isGradient) this.activeStop().format = v;
|
|
1081
|
+
else this.solidFormat = v;
|
|
1082
|
+
},
|
|
1083
|
+
|
|
1084
|
+
toRgb() { return hsvToRgb(this.h, this.s, this.v); },
|
|
1085
|
+
toHex() { const {r,g,b} = this.toRgb(); return rgbToHex(r,g,b); },
|
|
1086
|
+
toFormattedString() {
|
|
1087
|
+
if (this.isGradient) return buildFullGradientString(this.layers);
|
|
1088
|
+
const {r,g,b} = this.toRgb();
|
|
1089
|
+
return formatColor(r, g, b, this.a, this.solidFormat);
|
|
1090
|
+
},
|
|
1091
|
+
toActiveColorString() {
|
|
1092
|
+
const {r,g,b} = this.toRgb();
|
|
1093
|
+
return formatColor(r, g, b, this.a, this.format);
|
|
1094
|
+
},
|
|
1095
|
+
toSwatchColor() {
|
|
1096
|
+
if (this.isGradient) return buildFullGradientString(this.layers);
|
|
1097
|
+
const {r,g,b} = this.toRgb();
|
|
1098
|
+
if (this.a < 1) return `rgba(${r},${g},${b},${this.a})`;
|
|
1099
|
+
return this.toHex();
|
|
1100
|
+
},
|
|
1101
|
+
|
|
1102
|
+
// ---- Mutators ----
|
|
1103
|
+
|
|
1104
|
+
setFromString(str) {
|
|
1105
|
+
if (typeof str !== 'string') return false;
|
|
1106
|
+
// Gradient input → parse into layers + activate gradient mode.
|
|
1107
|
+
if (/gradient\s*\(/i.test(str)) {
|
|
1108
|
+
const layers = parseGradientString(str);
|
|
1109
|
+
if (!layers || !layers.length) return false;
|
|
1110
|
+
this.layers = layers;
|
|
1111
|
+
this.activeLayerIndex = 0;
|
|
1112
|
+
this.activeStopIndex = 0;
|
|
1113
|
+
this.openStop = null;
|
|
1114
|
+
// Seed the picker's working solid color from the first stop so
|
|
1115
|
+
// syncUI / hex output / canvas reflect something coherent.
|
|
1116
|
+
const firstStop = layers[0].stops[0];
|
|
1117
|
+
if (firstStop) {
|
|
1118
|
+
this.h = firstStop.color.h; this.s = firstStop.color.s;
|
|
1119
|
+
this.v = firstStop.color.v; this.a = firstStop.color.a;
|
|
1120
|
+
}
|
|
1121
|
+
if (this.pickerMode !== 'gradient') this.pickerMode = 'gradient';
|
|
1122
|
+
if (this.layersContainer) this.renderLayers();
|
|
1123
|
+
return true;
|
|
1124
|
+
}
|
|
1125
|
+
// Solid input → existing behavior.
|
|
1126
|
+
const parsed = parseCssColor(str);
|
|
1127
|
+
if (!parsed) return false;
|
|
1128
|
+
const hsv = rgbToHsv(parsed.r, parsed.g, parsed.b);
|
|
1129
|
+
this.h = hsv.h; this.s = hsv.s; this.v = hsv.v; this.a = parsed.a;
|
|
1130
|
+
const fmt = detectFormat(str);
|
|
1131
|
+
if (fmt) this.format = fmt;
|
|
1132
|
+
return true;
|
|
1133
|
+
},
|
|
1134
|
+
|
|
1135
|
+
selectStop(layerIndex, stopIndex) {
|
|
1136
|
+
this.activeLayerIndex = layerIndex;
|
|
1137
|
+
this.activeStopIndex = stopIndex;
|
|
1138
|
+
},
|
|
1139
|
+
|
|
1140
|
+
toggleStop(layerIndex, stopIndex) {
|
|
1141
|
+
const same = this.openStop && this.openStop.layerIndex === layerIndex && this.openStop.stopIndex === stopIndex;
|
|
1142
|
+
this.openStop = same ? null : { layerIndex, stopIndex };
|
|
1143
|
+
this.activeLayerIndex = layerIndex;
|
|
1144
|
+
this.activeStopIndex = stopIndex;
|
|
1145
|
+
this.renderLayers();
|
|
1146
|
+
// Gradient swatches in the library must be disabled while a stop is open
|
|
1147
|
+
// (CSS gradients can't contain nested gradients as stop colors).
|
|
1148
|
+
this._syncStopGradientDisable();
|
|
1149
|
+
},
|
|
1150
|
+
|
|
1151
|
+
addLayer() {
|
|
1152
|
+
this.layers.push(makeDefaultLayer());
|
|
1153
|
+
this.activeLayerIndex = this.layers.length - 1;
|
|
1154
|
+
this.activeStopIndex = 0;
|
|
1155
|
+
this.renderLayers(); this.syncToInput();
|
|
1156
|
+
},
|
|
1157
|
+
|
|
1158
|
+
// Insert a fresh default layer relative to the given index.
|
|
1159
|
+
// position: 'above' inserts before `i`; 'below' (default) inserts after.
|
|
1160
|
+
addLayerAt(i, position) {
|
|
1161
|
+
if (i == null || i < 0 || i > this.layers.length) return;
|
|
1162
|
+
const insertAt = position === 'above' ? i : i + 1;
|
|
1163
|
+
this.layers.splice(insertAt, 0, makeDefaultLayer());
|
|
1164
|
+
this.activeLayerIndex = insertAt;
|
|
1165
|
+
this.activeStopIndex = 0;
|
|
1166
|
+
this.renderLayers(); this.syncToInput();
|
|
1167
|
+
},
|
|
1168
|
+
|
|
1169
|
+
// Swap the layer at `i` with its neighbor. delta = -1 moves up, +1 moves down.
|
|
1170
|
+
// No-op at edges.
|
|
1171
|
+
moveLayer(i, delta) {
|
|
1172
|
+
const src = i;
|
|
1173
|
+
const dst = src + delta;
|
|
1174
|
+
if (src < 0 || src >= this.layers.length) return;
|
|
1175
|
+
if (dst < 0 || dst >= this.layers.length) return;
|
|
1176
|
+
const [layer] = this.layers.splice(src, 1);
|
|
1177
|
+
this.layers.splice(dst, 0, layer);
|
|
1178
|
+
// Preserve the "currently active layer" identity through the move
|
|
1179
|
+
if (this.activeLayerIndex === src) this.activeLayerIndex = dst;
|
|
1180
|
+
else if (delta > 0 && this.activeLayerIndex === dst) this.activeLayerIndex = src;
|
|
1181
|
+
else if (delta < 0 && this.activeLayerIndex === dst) this.activeLayerIndex = src;
|
|
1182
|
+
this.renderLayers(); this.syncToInput();
|
|
1183
|
+
},
|
|
1184
|
+
|
|
1185
|
+
duplicateLayer(i) {
|
|
1186
|
+
const src = this.layers[i]; if (!src) return;
|
|
1187
|
+
this.layers.splice(i + 1, 0, {
|
|
1188
|
+
type: src.type, angle: src.angle, position: { ...src.position },
|
|
1189
|
+
stops: src.stops.map(s => ({ color: { ...s.color }, position: s.position, format: s.format || 'hex' }))
|
|
1190
|
+
});
|
|
1191
|
+
this.activeLayerIndex = i + 1; this.activeStopIndex = 0;
|
|
1192
|
+
this.renderLayers(); this.syncToInput();
|
|
1193
|
+
},
|
|
1194
|
+
|
|
1195
|
+
removeLayer(i) {
|
|
1196
|
+
if (this.layers.length <= 1) return;
|
|
1197
|
+
this.layers.splice(i, 1);
|
|
1198
|
+
if (this.activeLayerIndex >= this.layers.length) this.activeLayerIndex = this.layers.length - 1;
|
|
1199
|
+
this.activeStopIndex = 0;
|
|
1200
|
+
this.renderLayers(); this.syncToInput();
|
|
1201
|
+
},
|
|
1202
|
+
|
|
1203
|
+
flipLayer(i) {
|
|
1204
|
+
const layer = this.layers[i]; if (!layer) return;
|
|
1205
|
+
for (const stop of layer.stops) stop.position = 100 - stop.position;
|
|
1206
|
+
this.renderLayers(); this.syncToInput();
|
|
1207
|
+
},
|
|
1208
|
+
|
|
1209
|
+
rotateLayer(i) {
|
|
1210
|
+
const layer = this.layers[i]; if (!layer) return;
|
|
1211
|
+
layer.angle = (layer.angle + 90) % 360;
|
|
1212
|
+
this.renderLayers(); this.syncToInput();
|
|
1213
|
+
},
|
|
1214
|
+
|
|
1215
|
+
setGradientType(i, type) {
|
|
1216
|
+
const layer = this.layers[i]; if (!layer) return;
|
|
1217
|
+
if (!GRADIENT_TYPES.includes(type)) return;
|
|
1218
|
+
layer.type = type;
|
|
1219
|
+
this.renderLayers(); this.syncToInput();
|
|
1220
|
+
},
|
|
1221
|
+
|
|
1222
|
+
addStop(layerIndex, position) {
|
|
1223
|
+
const layer = this.layers[layerIndex]; if (!layer) return;
|
|
1224
|
+
const sorted = layer.stops.slice().sort((a,b) => a.position - b.position);
|
|
1225
|
+
let before = sorted[0], after = sorted[sorted.length - 1];
|
|
1226
|
+
for (let k = 0; k < sorted.length - 1; k++) {
|
|
1227
|
+
if (sorted[k].position <= position && sorted[k+1].position >= position) {
|
|
1228
|
+
before = sorted[k]; after = sorted[k+1]; break;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
const range = after.position - before.position;
|
|
1232
|
+
const t = range === 0 ? 0.5 : (position - before.position) / range;
|
|
1233
|
+
layer.stops.push({
|
|
1234
|
+
color: {
|
|
1235
|
+
h: before.color.h + (after.color.h - before.color.h) * t,
|
|
1236
|
+
s: before.color.s + (after.color.s - before.color.s) * t,
|
|
1237
|
+
v: before.color.v + (after.color.v - before.color.v) * t,
|
|
1238
|
+
a: before.color.a + (after.color.a - before.color.a) * t,
|
|
1239
|
+
},
|
|
1240
|
+
position, format: before.format || 'hex'
|
|
1241
|
+
});
|
|
1242
|
+
this.activeLayerIndex = layerIndex;
|
|
1243
|
+
this.activeStopIndex = layer.stops.length - 1;
|
|
1244
|
+
this.renderLayers(); this.syncToInput();
|
|
1245
|
+
},
|
|
1246
|
+
|
|
1247
|
+
duplicateStop(layerIndex, stopIndex) {
|
|
1248
|
+
const layer = this.layers[layerIndex]; if (!layer) return;
|
|
1249
|
+
const src = layer.stops[stopIndex]; if (!src) return;
|
|
1250
|
+
layer.stops.push({ color: { ...src.color }, position: Math.min(100, src.position + 5), format: src.format || 'hex' });
|
|
1251
|
+
this.activeLayerIndex = layerIndex;
|
|
1252
|
+
this.activeStopIndex = layer.stops.length - 1;
|
|
1253
|
+
this.renderLayers(); this.syncToInput();
|
|
1254
|
+
},
|
|
1255
|
+
|
|
1256
|
+
deleteStop(layerIndex, stopIndex) {
|
|
1257
|
+
const layer = this.layers[layerIndex]; if (!layer) return;
|
|
1258
|
+
if (layer.stops.length <= 2) return;
|
|
1259
|
+
layer.stops.splice(stopIndex, 1);
|
|
1260
|
+
if (this.activeLayerIndex === layerIndex && this.activeStopIndex >= layer.stops.length)
|
|
1261
|
+
this.activeStopIndex = layer.stops.length - 1;
|
|
1262
|
+
this.renderLayers(); this.syncToInput();
|
|
1263
|
+
},
|
|
1264
|
+
|
|
1265
|
+
applyColor(str) {
|
|
1266
|
+
// Pure setter — Recent-list commits happen at the picker-close boundary
|
|
1267
|
+
// (popover close / inline focusout), not on every call.
|
|
1268
|
+
if (this.setFromString(str)) {
|
|
1269
|
+
this.syncUI();
|
|
1270
|
+
this.syncToInput();
|
|
1271
|
+
if (this.isGradient) this._refreshActiveStopVisuals();
|
|
1272
|
+
}
|
|
1273
|
+
},
|
|
1274
|
+
|
|
1275
|
+
// Toggle `.active` on library swatches whose canonical key matches the
|
|
1276
|
+
// picker's current color. Gradients compare as their full CSS string;
|
|
1277
|
+
// solids compare as 8-digit hex. Runs across every registered library
|
|
1278
|
+
// container (tab + any nested containers such as stop context menus).
|
|
1279
|
+
_updateActiveSwatches() {
|
|
1280
|
+
if (!this.libraryContainers.length) return;
|
|
1281
|
+
const current = this.isGradient
|
|
1282
|
+
? this.toFormattedString()
|
|
1283
|
+
: (() => {
|
|
1284
|
+
const {r,g,b} = this.toRgb();
|
|
1285
|
+
return rgbToHex8(r, g, b, this.a);
|
|
1286
|
+
})();
|
|
1287
|
+
const key = _swatchKeyOf(current);
|
|
1288
|
+
for (const c of this.libraryContainers) {
|
|
1289
|
+
if (!c.isConnected) continue;
|
|
1290
|
+
const nodes = c.querySelectorAll('[data-cp-key]');
|
|
1291
|
+
for (const n of nodes) n.classList.toggle('active', key != null && n.getAttribute('data-cp-key') === key);
|
|
1292
|
+
}
|
|
1293
|
+
this._syncStopGradientDisable();
|
|
1294
|
+
},
|
|
1295
|
+
|
|
1296
|
+
// Gradient-valued library swatches must be un-pickable when the click would
|
|
1297
|
+
// try to apply that gradient as a gradient-stop color (CSS doesn't allow
|
|
1298
|
+
// nested gradients in stop positions). That's the case when:
|
|
1299
|
+
// • a stop accordion is currently open (openStop set), OR
|
|
1300
|
+
// • the library container itself is nested inside a stop-context-menu —
|
|
1301
|
+
// every click in such a menu targets the right-clicked stop's color.
|
|
1302
|
+
// Dev CSS styles [disabled] on apply-color elements; the click handler
|
|
1303
|
+
// also short-circuits as belt-and-braces.
|
|
1304
|
+
_syncStopGradientDisable() {
|
|
1305
|
+
const stopIsOpen = !!this.openStop;
|
|
1306
|
+
for (const c of this.libraryContainers) {
|
|
1307
|
+
if (!c.isConnected) continue;
|
|
1308
|
+
const containerInStopMenu = !!c.closest('menu[id^="stop-context-menu"]');
|
|
1309
|
+
const shouldDisable = stopIsOpen || containerInStopMenu;
|
|
1310
|
+
const gradNodes = c.querySelectorAll('[data-cp-key]');
|
|
1311
|
+
for (const n of gradNodes) {
|
|
1312
|
+
const k = n.getAttribute('data-cp-key') || '';
|
|
1313
|
+
const isGradient = k.includes('gradient(');
|
|
1314
|
+
if (!isGradient) continue;
|
|
1315
|
+
if (shouldDisable) n.setAttribute('disabled', '');
|
|
1316
|
+
else n.removeAttribute('disabled');
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
},
|
|
1320
|
+
|
|
1321
|
+
// ---- Recent-list commit cycle ----
|
|
1322
|
+
//
|
|
1323
|
+
// Rules:
|
|
1324
|
+
// • Popover pickers commit on toggle→closed.
|
|
1325
|
+
// • Inline pickers commit on focusout past the rootEl.
|
|
1326
|
+
// • A commit pushes the current color to Recent IFF the user made a
|
|
1327
|
+
// non-library change since the last baseline and the color actually differs.
|
|
1328
|
+
// • Gradient mode never commits (stops are part of a gradient, not standalone).
|
|
1329
|
+
// • Programmatic api.applyColor calls don't mark user changes — only UI paths do.
|
|
1330
|
+
|
|
1331
|
+
_startCommitCycle() {
|
|
1332
|
+
this._recentBaseline = this._currentCommitValue();
|
|
1333
|
+
this._lastChangeFromLibrary = false;
|
|
1334
|
+
this._hasUserChange = false;
|
|
1335
|
+
},
|
|
1336
|
+
|
|
1337
|
+
_markUserChange(fromLibrary) {
|
|
1338
|
+
this._hasUserChange = true;
|
|
1339
|
+
this._lastChangeFromLibrary = !!fromLibrary;
|
|
1340
|
+
},
|
|
1341
|
+
|
|
1342
|
+
_currentCommitValue() {
|
|
1343
|
+
// Solid: canonical hex (format-independent). Gradient: full CSS string.
|
|
1344
|
+
try { return this.isGradient ? this.toFormattedString() : this.toHex(); }
|
|
1345
|
+
catch { return null; }
|
|
1346
|
+
},
|
|
1347
|
+
|
|
1348
|
+
_tryCommitRecent() {
|
|
1349
|
+
if (!this._hasUserChange) return; // no interaction since baseline
|
|
1350
|
+
if (this._lastChangeFromLibrary) return; // library picks don't count
|
|
1351
|
+
const current = this._currentCommitValue();
|
|
1352
|
+
if (!current) return;
|
|
1353
|
+
if (current === this._recentBaseline) return; // no actual change
|
|
1354
|
+
pushRecent(current);
|
|
1355
|
+
// Start a fresh cycle so repeated inline commits behave correctly
|
|
1356
|
+
this._startCommitCycle();
|
|
1357
|
+
},
|
|
1358
|
+
|
|
1359
|
+
// Shared-picker write-back: when a swatch with x-model triggered this picker,
|
|
1360
|
+
// push the current color through the swatch's model setter on commit. Unlike
|
|
1361
|
+
// Recent, this runs even when the change came from a library click — the user
|
|
1362
|
+
// clicked a library swatch intending to assign that color to their field.
|
|
1363
|
+
_commitToTrigger() {
|
|
1364
|
+
const trigger = this.triggerBtn;
|
|
1365
|
+
if (!trigger || typeof trigger._cpModelSetter !== 'function') return;
|
|
1366
|
+
if (!this._hasUserChange) return; // nothing to persist
|
|
1367
|
+
const value = this._currentCommitValue();
|
|
1368
|
+
if (value != null) trigger._cpModelSetter(value);
|
|
1369
|
+
},
|
|
1370
|
+
|
|
1371
|
+
async grabColor() {
|
|
1372
|
+
if (!window.EyeDropper) return;
|
|
1373
|
+
try {
|
|
1374
|
+
const result = await new EyeDropper().open();
|
|
1375
|
+
// Eyedropper is a single-color operation — route the result
|
|
1376
|
+
// to Solid mode and surface the solid controls. Mark as a
|
|
1377
|
+
// non-library user change; the usual commit boundary decides
|
|
1378
|
+
// whether it lands in Recent (user may tweak before closing).
|
|
1379
|
+
this._switchToSolidMode();
|
|
1380
|
+
this._markUserChange(false);
|
|
1381
|
+
this.applyColor(result.sRGBHex);
|
|
1382
|
+
} catch (e) { /* user cancelled */ }
|
|
1383
|
+
},
|
|
1384
|
+
|
|
1385
|
+
// Force the picker into solid mode and activate solid controls.
|
|
1386
|
+
// Also updates Alpine `tab` data (if the dev uses a tab-driven layout).
|
|
1387
|
+
_switchToSolidMode() {
|
|
1388
|
+
this.pickerMode = 'solid';
|
|
1389
|
+
this.openStop = null;
|
|
1390
|
+
const rootStack = this.rootEl._x_dataStack;
|
|
1391
|
+
const autoStack = this._autoTabScope?._x_dataStack;
|
|
1392
|
+
if (rootStack && rootStack[0] && 'tab' in rootStack[0]) rootStack[0].tab = 'solid';
|
|
1393
|
+
else if (autoStack && autoStack[0] && 'tab' in autoStack[0]) autoStack[0].tab = 'solid';
|
|
1394
|
+
if (this.solidTabRefs) this._activateControls(this.solidTabRefs);
|
|
1395
|
+
if (this.layersContainer) this.renderLayers();
|
|
1396
|
+
},
|
|
1397
|
+
|
|
1398
|
+
// Switch picker mode without touching the dev's `tab` Alpine data.
|
|
1399
|
+
// Used by library-tab applies — the user is browsing swatches and
|
|
1400
|
+
// shouldn't be flipped off the Library tab when they pick one.
|
|
1401
|
+
_setPickerMode(mode) {
|
|
1402
|
+
if (this.pickerMode === mode) return;
|
|
1403
|
+
this.pickerMode = mode;
|
|
1404
|
+
this.openStop = null;
|
|
1405
|
+
if (mode === 'solid' && this.solidTabRefs) this._activateControls(this.solidTabRefs);
|
|
1406
|
+
if (mode === 'gradient') this._activateControls(null);
|
|
1407
|
+
if (this.layersContainer) this.renderLayers();
|
|
1408
|
+
},
|
|
1409
|
+
|
|
1410
|
+
// Setter methods (mirror .set-* modifiers)
|
|
1411
|
+
setHue(v) {
|
|
1412
|
+
this.h = v;
|
|
1413
|
+
this.drawCanvas(); this.updateCanvasMarker();
|
|
1414
|
+
this.syncToInput(); this.updateColorInput();
|
|
1415
|
+
if (this.isGradient) this._refreshActiveStopVisuals();
|
|
1416
|
+
},
|
|
1417
|
+
setAlpha(v) {
|
|
1418
|
+
this.a = Math.max(0, Math.min(1, v));
|
|
1419
|
+
this.syncToInput(); this.updateColorInput(); this.updateAlphaInput();
|
|
1420
|
+
if (this.isGradient) this._refreshActiveStopVisuals();
|
|
1421
|
+
},
|
|
1422
|
+
setAlphaValue(percent) { this.setAlpha(percent / 100); },
|
|
1423
|
+
setColorSpace(fmt) {
|
|
1424
|
+
if (!FORMATS.includes(fmt)) return;
|
|
1425
|
+
this.format = fmt;
|
|
1426
|
+
this.updateColorInput();
|
|
1427
|
+
this._refreshFormatLabels();
|
|
1428
|
+
},
|
|
1429
|
+
|
|
1430
|
+
// Display label for each format (e.g. button text in a custom format dropdown)
|
|
1431
|
+
_formatLabel(fmt) {
|
|
1432
|
+
switch ((fmt || '').toLowerCase()) {
|
|
1433
|
+
case 'hex': return 'Hex';
|
|
1434
|
+
case 'rgb': return 'RGB';
|
|
1435
|
+
case 'hsl': return 'HSL';
|
|
1436
|
+
case 'oklch': return 'OKLCH';
|
|
1437
|
+
default: return fmt || '';
|
|
1438
|
+
}
|
|
1439
|
+
},
|
|
1440
|
+
|
|
1441
|
+
// Sync any registered format label elements to the current format and
|
|
1442
|
+
// toggle an `active` class on each format-choice element so devs can
|
|
1443
|
+
// style the active option without writing reactive bindings.
|
|
1444
|
+
_refreshFormatLabels() {
|
|
1445
|
+
const current = this.format;
|
|
1446
|
+
const label = this._formatLabel(current);
|
|
1447
|
+
for (const el of this.formatLabelEls) {
|
|
1448
|
+
if (!el || !el.isConnected) continue;
|
|
1449
|
+
if (el.textContent !== label) el.textContent = label;
|
|
1450
|
+
}
|
|
1451
|
+
for (const { el, fmt } of this.formatChoiceEls) {
|
|
1452
|
+
if (!el || !el.isConnected) continue;
|
|
1453
|
+
el.classList.toggle('active', fmt === current);
|
|
1454
|
+
}
|
|
1455
|
+
},
|
|
1456
|
+
setColorValue(str) {
|
|
1457
|
+
if (this.setFromString(str)) {
|
|
1458
|
+
this.drawCanvas(); this.updateCanvasMarker(); this.updateSliders();
|
|
1459
|
+
this.updateAlphaInput(); this.syncToInput();
|
|
1460
|
+
if (this.isGradient) this._refreshActiveStopVisuals();
|
|
1461
|
+
}
|
|
1462
|
+
},
|
|
1463
|
+
setAngle(i, degrees) {
|
|
1464
|
+
const layer = this.layers[i]; if (!layer) return;
|
|
1465
|
+
layer.angle = ((degrees % 360) + 360) % 360;
|
|
1466
|
+
this._refreshLayerVisuals(i);
|
|
1467
|
+
this.syncToInput();
|
|
1468
|
+
},
|
|
1469
|
+
setGradientValue(cssString) {
|
|
1470
|
+
// Edits don't parse back into layers; plugin writes the CSS var for the swatch.
|
|
1471
|
+
if (this.triggerBtn) this.triggerBtn.style.setProperty('--color-picker-swatch', cssString);
|
|
1472
|
+
},
|
|
1473
|
+
|
|
1474
|
+
// ---- Sync / render ----
|
|
1475
|
+
|
|
1476
|
+
syncToInput() {
|
|
1477
|
+
const swatchVal = this.toSwatchColor();
|
|
1478
|
+
if (this.hiddenInput) {
|
|
1479
|
+
// Native <input type="color"> only accepts #rrggbb; everything
|
|
1480
|
+
// else (synthesized type=hidden, dev-supplied type=hidden) gets
|
|
1481
|
+
// the full CSS string — solid color or gradient — so gradients
|
|
1482
|
+
// and non-hex formats round-trip without lossy conversion.
|
|
1483
|
+
const isNativeColorInput = this.hiddenInput.type === 'color';
|
|
1484
|
+
this.hiddenInput.value = isNativeColorInput ? this.toHex() : swatchVal;
|
|
1485
|
+
this.hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1486
|
+
this.hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1487
|
+
}
|
|
1488
|
+
if (this.triggerBtn) this.triggerBtn.style.setProperty('--color-picker-swatch', swatchVal);
|
|
1489
|
+
this.updateGradientValue();
|
|
1490
|
+
// Reflect current color in the library: any swatch whose canonical key
|
|
1491
|
+
// matches gets an `.active` class, all others lose it.
|
|
1492
|
+
this._updateActiveSwatches();
|
|
1493
|
+
// Refresh any custom format labels / choice highlights — covers paths
|
|
1494
|
+
// where format changes implicitly (setFromString parsing a new format).
|
|
1495
|
+
this._refreshFormatLabels();
|
|
1496
|
+
|
|
1497
|
+
// Bump reactive version — any $colorpicker(id).* reader re-runs.
|
|
1498
|
+
// No eager computation of hex/css/etc. unless somebody is actually bound to them.
|
|
1499
|
+
this.snapshot.version++;
|
|
1500
|
+
},
|
|
1501
|
+
|
|
1502
|
+
syncUI() {
|
|
1503
|
+
this.drawCanvas();
|
|
1504
|
+
this.updateSliders();
|
|
1505
|
+
this.updateColorInput();
|
|
1506
|
+
this.updateAlphaInput();
|
|
1507
|
+
this.updateCanvasMarker();
|
|
1508
|
+
},
|
|
1509
|
+
|
|
1510
|
+
drawCanvas() {
|
|
1511
|
+
const canvas = this.activeControls?.canvas;
|
|
1512
|
+
if (!canvas) return;
|
|
1513
|
+
const rect = canvas.getBoundingClientRect();
|
|
1514
|
+
if (rect.width <= 0) return; // not visible yet; ResizeObserver will redraw when it gains size
|
|
1515
|
+
let dimsChanged = false;
|
|
1516
|
+
if (canvas.width !== rect.width) { canvas.width = rect.width; dimsChanged = true; }
|
|
1517
|
+
if (canvas.height !== rect.height) { canvas.height = rect.height; dimsChanged = true; }
|
|
1518
|
+
// Skip repaint if hue unchanged and dimensions unchanged (setting canvas.* dims clears the pixels)
|
|
1519
|
+
if (!dimsChanged && canvas._cpLastHue === this.h) return;
|
|
1520
|
+
drawSvCanvas(canvas, this.h);
|
|
1521
|
+
canvas._cpLastHue = this.h;
|
|
1522
|
+
},
|
|
1523
|
+
|
|
1524
|
+
updateSliders() {
|
|
1525
|
+
const ac = this.activeControls; if (!ac) return;
|
|
1526
|
+
if (ac.hueSlider && document.activeElement !== ac.hueSlider) ac.hueSlider.value = this.h;
|
|
1527
|
+
if (ac.alphaSlider && document.activeElement !== ac.alphaSlider) {
|
|
1528
|
+
ac.alphaSlider.value = Math.round(this.a * 100);
|
|
1529
|
+
const {r,g,b} = this.toRgb();
|
|
1530
|
+
ac.alphaSlider.style.setProperty('--color-picker-alpha', `rgb(${r},${g},${b})`);
|
|
1531
|
+
}
|
|
1532
|
+
},
|
|
1533
|
+
|
|
1534
|
+
updateCanvasMarker() {
|
|
1535
|
+
const reticle = this.activeControls?.reticle; if (!reticle) return;
|
|
1536
|
+
reticle.style.left = this.s + '%';
|
|
1537
|
+
reticle.style.top = (100 - this.v) + '%';
|
|
1538
|
+
},
|
|
1539
|
+
|
|
1540
|
+
updateColorInput() {
|
|
1541
|
+
const ac = this.activeControls; if (!ac) return;
|
|
1542
|
+
if (ac.colorInput && document.activeElement !== ac.colorInput) ac.colorInput.value = this.toActiveColorString();
|
|
1543
|
+
if (ac.formatSelect && ac.formatSelect.value !== this.format) ac.formatSelect.value = this.format;
|
|
1544
|
+
},
|
|
1545
|
+
|
|
1546
|
+
updateAlphaInput() {
|
|
1547
|
+
const ac = this.activeControls; if (!ac) return;
|
|
1548
|
+
if (ac.alphaInput && document.activeElement !== ac.alphaInput) ac.alphaInput.value = Math.round(this.a * 100);
|
|
1549
|
+
},
|
|
1550
|
+
|
|
1551
|
+
updateGradientValue() {
|
|
1552
|
+
for (const ta of this.gradientValueInputs) {
|
|
1553
|
+
if (document.activeElement === ta) continue;
|
|
1554
|
+
ta.value = buildFullGradientString(this.layers);
|
|
1555
|
+
}
|
|
1556
|
+
},
|
|
1557
|
+
|
|
1558
|
+
// ---- Library rendering ----
|
|
1559
|
+
//
|
|
1560
|
+
// Templates are nested in natural HTML hierarchy (x-for style):
|
|
1561
|
+
// <template x-colorpicker.library>
|
|
1562
|
+
// <template x-colorpicker.library-group> <!-- scope: group -->
|
|
1563
|
+
// <template x-colorpicker.library-palette> <!-- scope: palette -->
|
|
1564
|
+
// <template x-colorpicker.library-swatch> <!-- scope: swatch -->
|
|
1565
|
+
// </template>
|
|
1566
|
+
// </template>
|
|
1567
|
+
// </template>
|
|
1568
|
+
// </template>
|
|
1569
|
+
//
|
|
1570
|
+
// Each inner <template> is replaced in-place by clones (siblings before it, then removed).
|
|
1571
|
+
// Data shape after normalization: [{ name?, colors?: Swatch[], palettes?: Palette[] }].
|
|
1572
|
+
// If a group has only `colors` (flat, e.g. Recent), it's auto-wrapped as a single
|
|
1573
|
+
// unnamed palette so nested templates still work uniformly.
|
|
1574
|
+
|
|
1575
|
+
renderLibrary() {
|
|
1576
|
+
if (this._libraryEffectBound) return;
|
|
1577
|
+
this._libraryEffectBound = true;
|
|
1578
|
+
if (!this.libraryContainers.length) return;
|
|
1579
|
+
// All registered containers share the same data source / render key. Use
|
|
1580
|
+
// the first for any Alpine scope evaluation that needs an element.
|
|
1581
|
+
const evalHost = this.libraryContainers[0];
|
|
1582
|
+
|
|
1583
|
+
if (this.libraryRootValue && window.Alpine?.effect && window.Alpine?.evaluateLater) {
|
|
1584
|
+
// Explicit expression — bypass discovery, re-render reactively when deps change
|
|
1585
|
+
const evalFn = Alpine.evaluateLater(evalHost, this.libraryRootValue);
|
|
1586
|
+
Alpine.effect(() => {
|
|
1587
|
+
evalFn(v => { this._libraryResolvedData = v; this._doRenderLibrary(); });
|
|
1588
|
+
});
|
|
1589
|
+
} else if (window.Alpine?.effect) {
|
|
1590
|
+
// Zero-config — read $x.manifest.colorpicker (string or array of source names)
|
|
1591
|
+
// and auto-merge those $x.* sources into the library. Everything happens
|
|
1592
|
+
// synchronously inside the effect so Alpine tracks all reactive deps ($locale,
|
|
1593
|
+
// $x.manifest, each $x.<name>) and re-runs the full pass on any change.
|
|
1594
|
+
// Evaluate against document.body so $x magic is always in scope (the
|
|
1595
|
+
// container may be detached/popover content without its own scope chain).
|
|
1596
|
+
const evalCtx = document.body;
|
|
1597
|
+
|
|
1598
|
+
// Manifest's data plugin REPLACES the $x.<source> proxy reference on load
|
|
1599
|
+
// rather than mutating in place. Alpine.effect can't catch that change via
|
|
1600
|
+
// property tracking, so we pair it with a short-lived poller that RE-READS
|
|
1601
|
+
// discovery until the data is loaded. We only actually re-render when the
|
|
1602
|
+
// serialized content has changed since the last render — otherwise each
|
|
1603
|
+
// poll tick would tear down and rebuild the whole library, thrashing the
|
|
1604
|
+
// DOM (and invalidating x-dropdown.context menu id lookups mid-flight).
|
|
1605
|
+
const keyOf = (names, collected) => {
|
|
1606
|
+
// Include the Recent list in the key so additions/removals trigger a
|
|
1607
|
+
// re-render. Reading _recentStore.list inside the Alpine.effect also
|
|
1608
|
+
// establishes reactivity on it, so cookie mutations (pushRecent /
|
|
1609
|
+
// removeRecent) fire the effect and change the key.
|
|
1610
|
+
const recentKey = _recentStore.list.slice(0, _recentMax).join(',');
|
|
1611
|
+
try { return recentKey + '#' + names.join('|') + '::' + JSON.stringify(collected); }
|
|
1612
|
+
catch { return recentKey + '#' + names.join('|') + '::[unserializable]'; }
|
|
1613
|
+
};
|
|
1614
|
+
const readSources = () => {
|
|
1615
|
+
let flag = null;
|
|
1616
|
+
try { flag = Alpine.evaluate(evalCtx, '$x && $x.manifest && $x.manifest.colorpicker'); } catch {}
|
|
1617
|
+
const raw = !flag ? [] : (Array.isArray(flag) ? flag : [flag]);
|
|
1618
|
+
const names = raw.filter(n => typeof n === 'string' && n.trim().length > 0);
|
|
1619
|
+
const collected = names.map(name => {
|
|
1620
|
+
try { return Alpine.evaluate(evalCtx, '$x.' + name); } catch { return null; }
|
|
1621
|
+
});
|
|
1622
|
+
const ready = names.length === 0 || collected.every(src => {
|
|
1623
|
+
if (!src || typeof src !== 'object') return false;
|
|
1624
|
+
return Object.keys(src).some(k => !k.startsWith('$')
|
|
1625
|
+
&& !k.startsWith('_')
|
|
1626
|
+
&& k !== 'contentType'
|
|
1627
|
+
&& k !== 'valueOf' && k !== 'toString');
|
|
1628
|
+
});
|
|
1629
|
+
return { names, collected, ready };
|
|
1630
|
+
};
|
|
1631
|
+
const runDiscovery = () => {
|
|
1632
|
+
const { names, collected, ready } = readSources();
|
|
1633
|
+
const key = keyOf(names, collected);
|
|
1634
|
+
if (key !== this._libraryDiscoveredKey) {
|
|
1635
|
+
this._libraryDiscoveredKey = key;
|
|
1636
|
+
this._libraryDiscoveredData = collected;
|
|
1637
|
+
this._doRenderLibrary();
|
|
1638
|
+
}
|
|
1639
|
+
return ready;
|
|
1640
|
+
};
|
|
1641
|
+
|
|
1642
|
+
// Reactive deps trigger re-runs on locale switch / manifest load.
|
|
1643
|
+
Alpine.effect(() => {
|
|
1644
|
+
try { Alpine.evaluate(evalCtx, '$locale && $locale.current'); } catch {}
|
|
1645
|
+
try { Alpine.evaluate(evalCtx, '$x && $x.manifest && $x.manifest._loadedFrom'); } catch {}
|
|
1646
|
+
runDiscovery();
|
|
1647
|
+
// Kick the poller only until data is ready — it re-checks every 150ms
|
|
1648
|
+
// but skips actual re-render when the content is unchanged.
|
|
1649
|
+
if (!this._libraryPollTimer) {
|
|
1650
|
+
let attempts = 0;
|
|
1651
|
+
this._libraryPollTimer = setInterval(() => {
|
|
1652
|
+
attempts++;
|
|
1653
|
+
if (runDiscovery() || attempts > 80) { // max ~12s
|
|
1654
|
+
clearInterval(this._libraryPollTimer);
|
|
1655
|
+
this._libraryPollTimer = null;
|
|
1656
|
+
}
|
|
1657
|
+
}, 150);
|
|
1658
|
+
}
|
|
1659
|
+
});
|
|
1660
|
+
} else {
|
|
1661
|
+
this._doRenderLibrary();
|
|
1662
|
+
}
|
|
1663
|
+
},
|
|
1664
|
+
|
|
1665
|
+
_resolveLibraryGroups() {
|
|
1666
|
+
if (this.libraryRootValue) {
|
|
1667
|
+
let data = this._libraryResolvedData;
|
|
1668
|
+
if (data == null
|
|
1669
|
+
|| (Array.isArray(data) && data.length === 0)
|
|
1670
|
+
|| (typeof data === 'object' && !Array.isArray(data) && _cleanLibraryEntries(data).length === 0)) {
|
|
1671
|
+
data = buildDefaultLibrary();
|
|
1672
|
+
}
|
|
1673
|
+
let groups = normalizeLibraryInput(data);
|
|
1674
|
+
const totalSwatches = groups.reduce((n, g) => n + (g.colors?.length || 0)
|
|
1675
|
+
+ (g.palettes?.reduce((m, p) => m + (p.colors?.length || 0), 0) || 0), 0);
|
|
1676
|
+
if (totalSwatches === 0) groups = normalizeLibraryInput(buildDefaultLibrary());
|
|
1677
|
+
return groups;
|
|
1678
|
+
}
|
|
1679
|
+
return composeLibraryFromSources(this._libraryDiscoveredData || []);
|
|
1680
|
+
},
|
|
1681
|
+
|
|
1682
|
+
// Render ONE container (used when a new container registers post-mount
|
|
1683
|
+
// — e.g. a gradient layer clone's inline library div). Avoids tearing
|
|
1684
|
+
// down every other container's x-dropdown.context init timers.
|
|
1685
|
+
_renderIntoContainer(container) {
|
|
1686
|
+
if (!container || !container.isConnected) return;
|
|
1687
|
+
const groups = this._resolveLibraryGroups();
|
|
1688
|
+
const layoutTpl = this.libraryTemplate || _defaultLibraryLayoutTpl;
|
|
1689
|
+
container.innerHTML = '';
|
|
1690
|
+
container.appendChild(layoutTpl.content.cloneNode(true));
|
|
1691
|
+
const groupTpl = container.querySelector('template[x-colorpicker\\.library-group]');
|
|
1692
|
+
if (groupTpl) {
|
|
1693
|
+
const parent = groupTpl.parentNode;
|
|
1694
|
+
for (const g of groups) parent.insertBefore(renderLibraryGroup(groupTpl, g), groupTpl);
|
|
1695
|
+
groupTpl.remove();
|
|
1696
|
+
} else {
|
|
1697
|
+
for (const g of groups) container.appendChild(renderDefaultGroup(g));
|
|
1698
|
+
}
|
|
1699
|
+
if (window.Alpine?.initTree) Alpine.initTree(container);
|
|
1700
|
+
// Newly-rendered swatches need active + gradient-disable state applied.
|
|
1701
|
+
this._updateActiveSwatches();
|
|
1702
|
+
},
|
|
1703
|
+
|
|
1704
|
+
_doRenderLibrary() {
|
|
1705
|
+
if (!this.libraryContainers.length) return;
|
|
1706
|
+
// Prune disconnected containers (removed by gradient layer re-render etc.)
|
|
1707
|
+
this.libraryContainers = this.libraryContainers.filter(c => c.isConnected);
|
|
1708
|
+
for (const container of this.libraryContainers) this._renderIntoContainer(container);
|
|
1709
|
+
// Active-class pass across all rendered swatches
|
|
1710
|
+
this._updateActiveSwatches();
|
|
1711
|
+
},
|
|
1712
|
+
|
|
1713
|
+
_activateControls(refs) {
|
|
1714
|
+
this.activeControls = refs || null;
|
|
1715
|
+
if (refs) this.syncUI();
|
|
1716
|
+
},
|
|
1717
|
+
|
|
1718
|
+
_refreshLayerVisuals(li) {
|
|
1719
|
+
const clone = this._getLayerClone(li);
|
|
1720
|
+
if (!clone) return;
|
|
1721
|
+
const bar = findInClone(clone, 'layer-stops-bar');
|
|
1722
|
+
if (bar) this._updateStopBarPreview(bar, this.layers[li]);
|
|
1723
|
+
},
|
|
1724
|
+
|
|
1725
|
+
_refreshActiveStopVisuals() {
|
|
1726
|
+
const clone = this._getLayerClone(this.activeLayerIndex);
|
|
1727
|
+
if (!clone) return;
|
|
1728
|
+
const bar = findInClone(clone, 'layer-stops-bar');
|
|
1729
|
+
if (bar) this._updateStopBarPreview(bar, this.activeLayer());
|
|
1730
|
+
const handle = bar?.querySelectorAll('[data-cp-stop-handle]')[this.activeStopIndex];
|
|
1731
|
+
if (handle) handle.style.backgroundColor = colorToRgba(this.activeStop().color);
|
|
1732
|
+
},
|
|
1733
|
+
|
|
1734
|
+
_updateStopBarPreview(barEl, layer) {
|
|
1735
|
+
const preview = layer.stops.slice().sort((a,b) => a.position - b.position)
|
|
1736
|
+
.map(s => `${colorToRgba(s.color)} ${s.position}%`).join(', ');
|
|
1737
|
+
barEl.style.background = `linear-gradient(to right, ${preview})`;
|
|
1738
|
+
},
|
|
1739
|
+
|
|
1740
|
+
_getLayerClone(li) {
|
|
1741
|
+
if (!this.layersContainer) return null;
|
|
1742
|
+
return this.layersContainer.querySelectorAll(':scope > [data-cp-layer-clone]')[li] || null;
|
|
1743
|
+
},
|
|
1744
|
+
|
|
1745
|
+
// ---- Layer rendering ----
|
|
1746
|
+
|
|
1747
|
+
renderLayers() {
|
|
1748
|
+
if (!this.layersContainer) return;
|
|
1749
|
+
|
|
1750
|
+
// Clamp openStop
|
|
1751
|
+
if (this.openStop) {
|
|
1752
|
+
const L = this.layers[this.openStop.layerIndex];
|
|
1753
|
+
if (!L || !L.stops[this.openStop.stopIndex]) this.openStop = null;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// Clear existing clones
|
|
1757
|
+
this.layersContainer.querySelectorAll(':scope > [data-cp-layer-clone]').forEach(el => el.remove());
|
|
1758
|
+
|
|
1759
|
+
// Get or synthesize layer template (parsed ONCE, cloned per layer)
|
|
1760
|
+
const layerTpl = this.layerTemplate || _defaultLayerTpl;
|
|
1761
|
+
|
|
1762
|
+
let pendingActivation = null;
|
|
1763
|
+
|
|
1764
|
+
this.layers.forEach((layer, li) => {
|
|
1765
|
+
const frag = layerTpl.content.cloneNode(true);
|
|
1766
|
+
const root = frag.firstElementChild;
|
|
1767
|
+
if (!root) return;
|
|
1768
|
+
|
|
1769
|
+
root.setAttribute('data-cp-layer-clone', '');
|
|
1770
|
+
root.setAttribute('data-gradient-type', layer.type);
|
|
1771
|
+
root._cpLayerIndex = li;
|
|
1772
|
+
|
|
1773
|
+
// Expose the layer's position + type to the clone's Alpine scope so
|
|
1774
|
+
// devs can bind classes/attributes reactively. Available in scope:
|
|
1775
|
+
// layerType — 'linear' | 'radial' | 'conic'
|
|
1776
|
+
// layerIndex — 0-based position of this layer
|
|
1777
|
+
// layerCount — total number of layers in the picker
|
|
1778
|
+
// Examples:
|
|
1779
|
+
// :class="'layer-type-' + layerType"
|
|
1780
|
+
// :disabled="layerIndex === 0" (Move Up)
|
|
1781
|
+
// :disabled="layerIndex === layerCount - 1" (Move Down)
|
|
1782
|
+
// :disabled="layerCount === 1" (Remove)
|
|
1783
|
+
root.setAttribute('x-data', '{ '
|
|
1784
|
+
+ 'layerType: ' + JSON.stringify(layer.type) + ', '
|
|
1785
|
+
+ 'layerIndex: ' + li + ', '
|
|
1786
|
+
+ 'layerCount: ' + this.layers.length
|
|
1787
|
+
+ ' }');
|
|
1788
|
+
|
|
1789
|
+
// Uniquify x-dropdown / x-dropdown.context / x-dropdown.hover IDs
|
|
1790
|
+
// within this clone so per-layer dropdowns don't collide.
|
|
1791
|
+
uniquifyDropdownIdsIn(root, this.pickerUid + '-layer-' + li);
|
|
1792
|
+
|
|
1793
|
+
// Render stops bar content
|
|
1794
|
+
const bar = findInClone(root, 'layer-stops-bar');
|
|
1795
|
+
if (bar) this._renderStopBar(bar, layer, li);
|
|
1796
|
+
|
|
1797
|
+
// Set initial angle input value
|
|
1798
|
+
const angleInput = findInClone(root, 'set-angle');
|
|
1799
|
+
if (angleInput) angleInput.value = layer.angle;
|
|
1800
|
+
|
|
1801
|
+
// Populate accordion solid panel if this is the open stop's layer
|
|
1802
|
+
const nestedSolidInstance = findInClone(root, 'solid');
|
|
1803
|
+
if (nestedSolidInstance && this.openStop && this.openStop.layerIndex === li) {
|
|
1804
|
+
const refs = this._mountSolidInstance(nestedSolidInstance);
|
|
1805
|
+
if (refs) pendingActivation = refs;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
this.layersContainer.appendChild(root);
|
|
1809
|
+
|
|
1810
|
+
// Let Alpine/Manifest process the clone (x-dropdown, x-icon, and nested x-colorpicker directives)
|
|
1811
|
+
if (window.Alpine?.initTree) {
|
|
1812
|
+
requestAnimationFrame(() => Alpine.initTree(root));
|
|
1813
|
+
}
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
if (pendingActivation) this._activateControls(pendingActivation);
|
|
1817
|
+
else if (this.isGradient) this._activateControls(null);
|
|
1818
|
+
},
|
|
1819
|
+
|
|
1820
|
+
_renderStopBar(barEl, layer, layerIndex) {
|
|
1821
|
+
barEl.innerHTML = '';
|
|
1822
|
+
this._updateStopBarPreview(barEl, layer);
|
|
1823
|
+
|
|
1824
|
+
// Drop-to-add-stop on bar click (non-handle clicks)
|
|
1825
|
+
barEl.onclick = (e) => {
|
|
1826
|
+
if (e.target.hasAttribute('data-cp-stop-handle')) return;
|
|
1827
|
+
const rect = barEl.getBoundingClientRect();
|
|
1828
|
+
this.addStop(layerIndex, Math.round(((e.clientX - rect.left) / rect.width) * 100));
|
|
1829
|
+
};
|
|
1830
|
+
|
|
1831
|
+
layer.stops.forEach((stop, si) => {
|
|
1832
|
+
const handle = document.createElement('div');
|
|
1833
|
+
handle.className = 'stop-handle';
|
|
1834
|
+
handle.setAttribute('data-cp-stop-handle', '');
|
|
1835
|
+
handle.style.left = stop.position + '%';
|
|
1836
|
+
handle.style.backgroundColor = colorToRgba(stop.color);
|
|
1837
|
+
// .active reflects whether this stop's accordion is currently open
|
|
1838
|
+
if (this.openStop && this.openStop.layerIndex === layerIndex && this.openStop.stopIndex === si) {
|
|
1839
|
+
handle.classList.add('active');
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
let dragging = false, startX = 0, moved = false, cachedBarRect = null;
|
|
1843
|
+
const self = this;
|
|
1844
|
+
const applyDrag = (e) => {
|
|
1845
|
+
if (!cachedBarRect) cachedBarRect = barEl.getBoundingClientRect();
|
|
1846
|
+
const rect = cachedBarRect;
|
|
1847
|
+
stop.position = Math.max(0, Math.min(100, Math.round(((e.clientX - rect.left) / rect.width) * 100)));
|
|
1848
|
+
handle.style.left = stop.position + '%';
|
|
1849
|
+
self._updateStopBarPreview(barEl, layer);
|
|
1850
|
+
self.syncToInput();
|
|
1851
|
+
};
|
|
1852
|
+
const throttledDrag = rafThrottle(applyDrag);
|
|
1853
|
+
handle.addEventListener('pointerdown', (e) => {
|
|
1854
|
+
if (e.button !== 0) return;
|
|
1855
|
+
e.stopPropagation();
|
|
1856
|
+
self.selectStop(layerIndex, si);
|
|
1857
|
+
// .active is set by the re-render after toggleStop;
|
|
1858
|
+
// don't preemptively toggle here (would be wrong for drag-without-toggle).
|
|
1859
|
+
dragging = true; moved = false; startX = e.clientX;
|
|
1860
|
+
cachedBarRect = barEl.getBoundingClientRect();
|
|
1861
|
+
handle.setPointerCapture(e.pointerId);
|
|
1862
|
+
});
|
|
1863
|
+
handle.addEventListener('pointermove', (e) => {
|
|
1864
|
+
if (!dragging) return;
|
|
1865
|
+
if (Math.abs(e.clientX - startX) > 3) moved = true;
|
|
1866
|
+
if (moved) throttledDrag(e);
|
|
1867
|
+
});
|
|
1868
|
+
handle.addEventListener('pointerup', () => {
|
|
1869
|
+
// Only toggle/cleanup if we had a valid left-click drag session.
|
|
1870
|
+
// Right-click never sets `dragging=true` (pointerdown bails for button!==0),
|
|
1871
|
+
// so this pointerup would otherwise still call toggleStop() and
|
|
1872
|
+
// destroy the layer clone — killing the context menu that just opened.
|
|
1873
|
+
if (!dragging) return;
|
|
1874
|
+
const wasMoved = moved;
|
|
1875
|
+
dragging = false; moved = false; cachedBarRect = null;
|
|
1876
|
+
if (!wasMoved) self.toggleStop(layerIndex, si);
|
|
1877
|
+
});
|
|
1878
|
+
barEl.appendChild(handle);
|
|
1879
|
+
});
|
|
1880
|
+
},
|
|
1881
|
+
|
|
1882
|
+
// ---- Solid instance mounting ----
|
|
1883
|
+
|
|
1884
|
+
_mountSolidInstance(containerEl) {
|
|
1885
|
+
if (!containerEl) return null;
|
|
1886
|
+
containerEl.innerHTML = '';
|
|
1887
|
+
const source = this.solidTemplate || _defaultSolidTpl;
|
|
1888
|
+
const frag = source.content.cloneNode(true);
|
|
1889
|
+
// Uniquify any x-dropdown menu ids inside the cloned solid panel
|
|
1890
|
+
// (e.g. the `color-space-menu` from the default template) so two
|
|
1891
|
+
// pickers on the same page don't share the same popover element.
|
|
1892
|
+
uniquifyDropdownIdsIn(frag, this.pickerUid + '-solid');
|
|
1893
|
+
containerEl.appendChild(frag);
|
|
1894
|
+
|
|
1895
|
+
const refs = this._collectSolidRefs(containerEl);
|
|
1896
|
+
this._wireSolidControls(refs);
|
|
1897
|
+
|
|
1898
|
+
// Let Alpine process any x-* directives in the mounted content
|
|
1899
|
+
if (window.Alpine?.initTree) {
|
|
1900
|
+
requestAnimationFrame(() => Alpine.initTree(containerEl));
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
return refs;
|
|
1904
|
+
},
|
|
1905
|
+
|
|
1906
|
+
// Mount the full gradient panel into an instance container.
|
|
1907
|
+
// Uses <template x-colorpicker.gradient> if provided, otherwise the default.
|
|
1908
|
+
_mountGradientInstance(containerEl) {
|
|
1909
|
+
if (!containerEl) return;
|
|
1910
|
+
containerEl.innerHTML = '';
|
|
1911
|
+
const source = this.gradientTemplate || _defaultGradientTpl;
|
|
1912
|
+
const frag = source.content.cloneNode(true);
|
|
1913
|
+
containerEl.appendChild(frag);
|
|
1914
|
+
|
|
1915
|
+
// Alpine processes the inner x-colorpicker.* directives (add-layer,
|
|
1916
|
+
// gradient-layers, layer-options template, set-gradient-value) which
|
|
1917
|
+
// register with THIS state via ancestor traversal.
|
|
1918
|
+
if (window.Alpine?.initTree) Alpine.initTree(containerEl);
|
|
1919
|
+
},
|
|
1920
|
+
|
|
1921
|
+
_collectSolidRefs(containerEl) {
|
|
1922
|
+
return {
|
|
1923
|
+
wrapper: containerEl.querySelector('.canvas-wrapper')
|
|
1924
|
+
|| findInClone(containerEl, 'set-canvas')?.parentElement,
|
|
1925
|
+
canvas: findInClone(containerEl, 'set-canvas'),
|
|
1926
|
+
reticle: containerEl.querySelector('.color-reticle'),
|
|
1927
|
+
hueSlider: findInClone(containerEl, 'set-hue'),
|
|
1928
|
+
alphaSlider: findInClone(containerEl, 'set-alpha'),
|
|
1929
|
+
colorInput: findInClone(containerEl, 'set-color-value'),
|
|
1930
|
+
alphaInput: findInClone(containerEl, 'set-alpha-value'),
|
|
1931
|
+
formatSelect: findInClone(containerEl, 'set-color-space'),
|
|
1932
|
+
};
|
|
1933
|
+
},
|
|
1934
|
+
|
|
1935
|
+
_wireSolidControls(refs) {
|
|
1936
|
+
const self = this;
|
|
1937
|
+
|
|
1938
|
+
// Canvas pointer + resize-driven redraw
|
|
1939
|
+
if (refs.canvas && refs.wrapper) {
|
|
1940
|
+
let dragging = false;
|
|
1941
|
+
let cachedRect = null;
|
|
1942
|
+
const pick = (e) => {
|
|
1943
|
+
// Cache the rect while dragging; invalidated on pointerdown
|
|
1944
|
+
if (!cachedRect) cachedRect = refs.canvas.getBoundingClientRect();
|
|
1945
|
+
const rect = cachedRect;
|
|
1946
|
+
self.s = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
|
|
1947
|
+
self.v = Math.max(0, Math.min(100, (1 - (e.clientY - rect.top) / rect.height) * 100));
|
|
1948
|
+
self.syncToInput(); self.updateSliders(); self.updateColorInput(); self.updateCanvasMarker();
|
|
1949
|
+
if (self.isGradient) self._refreshActiveStopVisuals();
|
|
1950
|
+
};
|
|
1951
|
+
const throttledPick = rafThrottle(pick);
|
|
1952
|
+
refs.wrapper.addEventListener('pointerdown', (e) => {
|
|
1953
|
+
dragging = true;
|
|
1954
|
+
cachedRect = refs.canvas.getBoundingClientRect();
|
|
1955
|
+
refs.wrapper.setPointerCapture(e.pointerId);
|
|
1956
|
+
self._markUserChange(false);
|
|
1957
|
+
pick(e); // immediate on first click (not throttled)
|
|
1958
|
+
});
|
|
1959
|
+
refs.wrapper.addEventListener('pointermove', (e) => { if (dragging) throttledPick(e); });
|
|
1960
|
+
refs.wrapper.addEventListener('pointerup', () => { dragging = false; cachedRect = null; });
|
|
1961
|
+
|
|
1962
|
+
const ro = new ResizeObserver(() => {
|
|
1963
|
+
if (self.activeControls?.canvas !== refs.canvas) return;
|
|
1964
|
+
const r = refs.canvas.getBoundingClientRect();
|
|
1965
|
+
if (r.width > 0 && r.height > 0) { self.drawCanvas(); self.updateCanvasMarker(); }
|
|
1966
|
+
});
|
|
1967
|
+
ro.observe(refs.canvas);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
if (refs.hueSlider) {
|
|
1971
|
+
refs.hueSlider.min = 0; refs.hueSlider.max = 360; refs.hueSlider.step = 1;
|
|
1972
|
+
refs.hueSlider.addEventListener('input', () => {
|
|
1973
|
+
self._markUserChange(false);
|
|
1974
|
+
self.setHue(parseFloat(refs.hueSlider.value));
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
if (refs.alphaSlider) {
|
|
1979
|
+
refs.alphaSlider.min = 0; refs.alphaSlider.max = 100; refs.alphaSlider.step = 1;
|
|
1980
|
+
refs.alphaSlider.addEventListener('input', () => {
|
|
1981
|
+
self._markUserChange(false);
|
|
1982
|
+
self.setAlpha(parseFloat(refs.alphaSlider.value) / 100);
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
if (refs.colorInput) {
|
|
1987
|
+
refs.colorInput.addEventListener('input', () => {
|
|
1988
|
+
self._markUserChange(false);
|
|
1989
|
+
self.setColorValue(refs.colorInput.value);
|
|
1990
|
+
});
|
|
1991
|
+
refs.colorInput.addEventListener('blur', () => { refs.colorInput.value = self.toActiveColorString(); });
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
if (refs.alphaInput) {
|
|
1995
|
+
refs.alphaInput.addEventListener('input', () => {
|
|
1996
|
+
const v = parseFloat(refs.alphaInput.value);
|
|
1997
|
+
if (!isNaN(v)) {
|
|
1998
|
+
self._markUserChange(false);
|
|
1999
|
+
self.setAlphaValue(v);
|
|
2000
|
+
}
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
if (refs.formatSelect) {
|
|
2005
|
+
refs.formatSelect.addEventListener('change', () => self.setColorSpace(refs.formatSelect.value));
|
|
2006
|
+
}
|
|
2007
|
+
},
|
|
2008
|
+
|
|
2009
|
+
// ---- Picker mount (after all children registered) ----
|
|
2010
|
+
|
|
2011
|
+
mount() {
|
|
2012
|
+
// Tier 2: if the container has no declared UI (templates are inert overrides),
|
|
2013
|
+
// inject the full default UI. Templates alone don't count as "declared UI".
|
|
2014
|
+
const noDeclared = !this.solidTemplate && !this.layerTemplate && !this.gradientTemplate
|
|
2015
|
+
&& this.solidInstances.length === 0 && this.gradientInstances.length === 0
|
|
2016
|
+
&& !this.layersContainer && this.gradientValueInputs.length === 0
|
|
2017
|
+
&& !this.libraryContainers.length;
|
|
2018
|
+
const hasNonTemplateChildren = [...this.rootEl.children].some(c => c.tagName !== 'TEMPLATE');
|
|
2019
|
+
if (noDeclared && !hasNonTemplateChildren) {
|
|
2020
|
+
this._injectDefaultUI();
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// Initialize from hidden input value
|
|
2024
|
+
const initVal = this.hiddenInput ? this.hiddenInput.value : '#000000';
|
|
2025
|
+
this.setFromString(initVal || '#000000');
|
|
2026
|
+
|
|
2027
|
+
// Seed trigger swatch
|
|
2028
|
+
if (this.triggerBtn) this.triggerBtn.style.setProperty('--color-picker-swatch', this.toSwatchColor());
|
|
2029
|
+
|
|
2030
|
+
// Mount all solid-panel instances (Solid tab + any others)
|
|
2031
|
+
let firstSolidRefs = null;
|
|
2032
|
+
for (const inst of this.solidInstances) {
|
|
2033
|
+
const refs = this._mountSolidInstance(inst);
|
|
2034
|
+
if (refs && !firstSolidRefs) firstSolidRefs = refs;
|
|
2035
|
+
}
|
|
2036
|
+
this.solidTabRefs = firstSolidRefs;
|
|
2037
|
+
|
|
2038
|
+
// Mount all gradient-panel instances (the full gradient panel).
|
|
2039
|
+
// This populates them with the gradient template (or default), which in turn
|
|
2040
|
+
// registers gradient-layers / layer-options / set-gradient-value with this state.
|
|
2041
|
+
for (const inst of this.gradientInstances) this._mountGradientInstance(inst);
|
|
2042
|
+
|
|
2043
|
+
// Render gradient layers (only if a container is declared)
|
|
2044
|
+
if (this.layersContainer) this.renderLayers();
|
|
2045
|
+
|
|
2046
|
+
// Initial mode based on Alpine `tab` data, if present
|
|
2047
|
+
this._syncPickerModeFromTab();
|
|
2048
|
+
|
|
2049
|
+
// Activate controls based on mode
|
|
2050
|
+
if (!this.isGradient) this._activateControls(this.solidTabRefs);
|
|
2051
|
+
|
|
2052
|
+
// Wire click-based tab watcher (root + auto-injected wrapper)
|
|
2053
|
+
this.rootEl.addEventListener('click', () => {
|
|
2054
|
+
requestAnimationFrame(() => this._syncPickerModeFromTab());
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
// Initial sync
|
|
2058
|
+
this.syncToInput();
|
|
2059
|
+
|
|
2060
|
+
// Render the library — _doRenderLibrary clones the (optional) library template
|
|
2061
|
+
// into the container and expands nested group/palette/swatch templates in-place.
|
|
2062
|
+
if (this.libraryContainers.length) this.renderLibrary();
|
|
2063
|
+
|
|
2064
|
+
// ---- Recent-list commit wiring ----
|
|
2065
|
+
// Seed the initial baseline. Popover pickers re-seed on toggle→open.
|
|
2066
|
+
this._startCommitCycle();
|
|
2067
|
+
|
|
2068
|
+
// Broad user-interaction detector: any pointerdown or input event inside
|
|
2069
|
+
// the picker marks the cycle as "user-touched". This covers all gradient
|
|
2070
|
+
// controls (add-layer, set-angle, stop drags, textarea edits, etc.) without
|
|
2071
|
+
// having to instrument each handler. Library swatches are detected by
|
|
2072
|
+
// scope ancestry so a preset pick is correctly flagged as library-sourced.
|
|
2073
|
+
this.rootEl.addEventListener('pointerdown', (e) => {
|
|
2074
|
+
const fromLibrary = !!e.target.closest('[x-data*="swatch:"]');
|
|
2075
|
+
this._markUserChange(fromLibrary);
|
|
2076
|
+
});
|
|
2077
|
+
this.rootEl.addEventListener('input', () => {
|
|
2078
|
+
// 'input' on form controls = user typing / dragging sliders
|
|
2079
|
+
this._markUserChange(false);
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
if (this.rootEl.hasAttribute('popover')) {
|
|
2083
|
+
// Popover mode: open/close are the commit boundaries
|
|
2084
|
+
this.rootEl.addEventListener('toggle', (e) => {
|
|
2085
|
+
if (e.newState === 'open') this._startCommitCycle();
|
|
2086
|
+
if (e.newState === 'closed') {
|
|
2087
|
+
// Write to the triggering swatch's model FIRST — _tryCommitRecent
|
|
2088
|
+
// resets the user-change flag on success, which would otherwise
|
|
2089
|
+
// short-circuit _commitToTrigger.
|
|
2090
|
+
this._commitToTrigger();
|
|
2091
|
+
this._tryCommitRecent();
|
|
2092
|
+
}
|
|
2093
|
+
});
|
|
2094
|
+
} else {
|
|
2095
|
+
// Inline mode: commit when focus leaves the picker entirely
|
|
2096
|
+
this.rootEl.addEventListener('focusout', (e) => {
|
|
2097
|
+
const moved = e.relatedTarget;
|
|
2098
|
+
if (!moved || !this.rootEl.contains(moved)) {
|
|
2099
|
+
this._commitToTrigger();
|
|
2100
|
+
this._tryCommitRecent();
|
|
2101
|
+
}
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
// Register in the global registry so $colorpicker(id) bindings resolve
|
|
2106
|
+
if (this.rootEl.id) _pickerRegistry[this.rootEl.id] = this.api;
|
|
2107
|
+
|
|
2108
|
+
// Mark complete so the `library` directive handler knows to render into
|
|
2109
|
+
// any newly-registered containers (e.g., gradient layer library menus).
|
|
2110
|
+
this._mounted = true;
|
|
2111
|
+
},
|
|
2112
|
+
|
|
2113
|
+
_injectDefaultUI() {
|
|
2114
|
+
this.rootEl.innerHTML = '';
|
|
2115
|
+
this.rootEl.appendChild(_defaultFullUiTpl.content.cloneNode(true));
|
|
2116
|
+
|
|
2117
|
+
// Filter / reorder tabs and panels per `allowedPanels` (set on the
|
|
2118
|
+
// root state when the directive expression is a panel-list array).
|
|
2119
|
+
// No allowedPanels → render all three in their default order.
|
|
2120
|
+
const allowed = this.allowedPanels;
|
|
2121
|
+
if (allowed && allowed.length) {
|
|
2122
|
+
const wrapper = this.rootEl.firstElementChild;
|
|
2123
|
+
const tabBar = wrapper?.querySelector('[data-cp-tabs]');
|
|
2124
|
+
// Remove tab buttons not in allowed; reorder the rest in `allowed` order
|
|
2125
|
+
if (tabBar) {
|
|
2126
|
+
const tabBtns = Array.from(tabBar.querySelectorAll('[data-cp-tab]'));
|
|
2127
|
+
for (const b of tabBtns) {
|
|
2128
|
+
if (!allowed.includes(b.getAttribute('data-cp-tab'))) b.remove();
|
|
2129
|
+
}
|
|
2130
|
+
for (const name of allowed) {
|
|
2131
|
+
const b = tabBar.querySelector(`[data-cp-tab="${name}"]`);
|
|
2132
|
+
if (b) tabBar.appendChild(b);
|
|
2133
|
+
}
|
|
2134
|
+
// Single panel → no tabs needed, drop the bar entirely
|
|
2135
|
+
if (allowed.length === 1) tabBar.remove();
|
|
2136
|
+
}
|
|
2137
|
+
// Remove panel containers not in allowed; reorder the rest
|
|
2138
|
+
const panels = Array.from(wrapper?.querySelectorAll('[data-cp-panel]') || []);
|
|
2139
|
+
for (const p of panels) {
|
|
2140
|
+
if (!allowed.includes(p.getAttribute('data-cp-panel'))) p.remove();
|
|
2141
|
+
}
|
|
2142
|
+
// Reset the initial `tab` x-data to the first allowed panel and
|
|
2143
|
+
// strip x-show so the lone panel always renders when there's no tab bar.
|
|
2144
|
+
if (wrapper.hasAttribute('x-data')) {
|
|
2145
|
+
wrapper.setAttribute('x-data', `{ tab: '${allowed[0]}' }`);
|
|
2146
|
+
}
|
|
2147
|
+
if (allowed.length === 1) {
|
|
2148
|
+
const lone = wrapper.querySelector(`[data-cp-panel="${allowed[0]}"]`);
|
|
2149
|
+
if (lone) lone.removeAttribute('x-show');
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
// Alpine.initTree fires all x-* directives in the newly injected content,
|
|
2154
|
+
// which will register solidTemplate/layerTemplate/solidInstances/layersContainer
|
|
2155
|
+
// against THIS state (since ancestor traversal finds this.rootEl).
|
|
2156
|
+
if (window.Alpine?.initTree) {
|
|
2157
|
+
Alpine.initTree(this.rootEl);
|
|
2158
|
+
}
|
|
2159
|
+
// Stash the auto-injected tab scope wrapper so _syncPickerModeFromTab can read it
|
|
2160
|
+
this._autoTabScope = this.rootEl.firstElementChild;
|
|
2161
|
+
},
|
|
2162
|
+
|
|
2163
|
+
_syncPickerModeFromTab() {
|
|
2164
|
+
const rootTab = this.rootEl._x_dataStack?.[0]?.tab;
|
|
2165
|
+
const autoTab = this._autoTabScope?._x_dataStack?.[0]?.tab;
|
|
2166
|
+
const tab = rootTab || autoTab;
|
|
2167
|
+
if (!tab) return;
|
|
2168
|
+
// Only Solid/Gradient tabs drive the edit mode — Library (or any other
|
|
2169
|
+
// tab) preserves the current mode. Otherwise switching to Library while
|
|
2170
|
+
// editing a gradient would silently demote the picker to solid, breaking
|
|
2171
|
+
// the "active" indicator on gradient Recent swatches.
|
|
2172
|
+
let newMode = null;
|
|
2173
|
+
if (tab === 'gradient') newMode = 'gradient';
|
|
2174
|
+
else if (tab === 'solid') newMode = 'solid';
|
|
2175
|
+
if (!newMode || newMode === this.pickerMode) return;
|
|
2176
|
+
this.pickerMode = newMode;
|
|
2177
|
+
if (this.isGradient) {
|
|
2178
|
+
this.renderLayers();
|
|
2179
|
+
if (!this.openStop) this._activateControls(null);
|
|
2180
|
+
} else {
|
|
2181
|
+
this.openStop = null;
|
|
2182
|
+
this._activateControls(this.solidTabRefs);
|
|
2183
|
+
}
|
|
2184
|
+
this.syncToInput();
|
|
2185
|
+
},
|
|
2186
|
+
};
|
|
2187
|
+
|
|
2188
|
+
// ---- Public API exposed via $colorpicker magic ----
|
|
2189
|
+
|
|
2190
|
+
// Reading `state.snapshot.version` inside each getter registers a reactive
|
|
2191
|
+
// dependency. When syncToInput bumps the version, any Alpine effect that
|
|
2192
|
+
// read these getters re-runs — and only then do we compute the value.
|
|
2193
|
+
// Zero computation if nothing is bound.
|
|
2194
|
+
const track = () => state.snapshot.version;
|
|
2195
|
+
state.api = {
|
|
2196
|
+
// Reactive reads — lazily computed on demand
|
|
2197
|
+
get hex() { track(); return state.toHex(); },
|
|
2198
|
+
get formatted() { track(); return state.toActiveColorString(); },
|
|
2199
|
+
get css() { track(); return state.toFormattedString(); },
|
|
2200
|
+
get h() { track(); return state.h; },
|
|
2201
|
+
get s() { track(); return state.s; },
|
|
2202
|
+
get v() { track(); return state.v; },
|
|
2203
|
+
get a() { track(); return state.a; },
|
|
2204
|
+
get format() { track(); return state.format; },
|
|
2205
|
+
get pickerMode() { track(); return state.pickerMode; },
|
|
2206
|
+
|
|
2207
|
+
// Default string coercion → current CSS value. Lets the developer write
|
|
2208
|
+
// :style="`background: ${$colorpicker('id')}`"
|
|
2209
|
+
// x-text="$colorpicker('id')"
|
|
2210
|
+
// and get the color string directly without picking a specific property.
|
|
2211
|
+
[Symbol.toPrimitive]() { track(); return state.toFormattedString(); },
|
|
2212
|
+
toString() { track(); return state.toFormattedString(); },
|
|
2213
|
+
valueOf() { track(); return state.toFormattedString(); },
|
|
2214
|
+
|
|
2215
|
+
// Non-reactive references (direct state)
|
|
2216
|
+
get layers() { return state.layers; },
|
|
2217
|
+
get activeLayer() { return state.activeLayer(); },
|
|
2218
|
+
get activeStop() { return state.activeStop(); },
|
|
2219
|
+
get activeLayerIndex() { return state.activeLayerIndex; },
|
|
2220
|
+
get activeStopIndex() { return state.activeStopIndex; },
|
|
2221
|
+
get openStop() { return state.openStop; },
|
|
2222
|
+
|
|
2223
|
+
// Actions (mirror `.action` modifiers)
|
|
2224
|
+
addLayer: () => state.addLayer(),
|
|
2225
|
+
addLayerAbove: (i) => state.addLayerAt(i ?? state.activeLayerIndex, 'above'),
|
|
2226
|
+
addLayerBelow: (i) => state.addLayerAt(i ?? state.activeLayerIndex, 'below'),
|
|
2227
|
+
moveLayerUp: (i) => state.moveLayer(i ?? state.activeLayerIndex, -1),
|
|
2228
|
+
moveLayerDown: (i) => state.moveLayer(i ?? state.activeLayerIndex, +1),
|
|
2229
|
+
duplicateLayer: (i) => state.duplicateLayer(i ?? state.activeLayerIndex),
|
|
2230
|
+
removeLayer: (i) => state.removeLayer(i ?? state.activeLayerIndex),
|
|
2231
|
+
flipLayer: (i) => state.flipLayer(i ?? state.activeLayerIndex),
|
|
2232
|
+
rotateLayer: (i) => state.rotateLayer(i ?? state.activeLayerIndex),
|
|
2233
|
+
duplicateStop: (li, si) => state.duplicateStop(li ?? state.activeLayerIndex, si ?? state.activeStopIndex),
|
|
2234
|
+
deleteStop: (li, si) => state.deleteStop(li ?? state.activeLayerIndex, si ?? state.activeStopIndex),
|
|
2235
|
+
addStop: (li, pos) => state.addStop(li ?? state.activeLayerIndex, pos),
|
|
2236
|
+
setGradientType: (i, type) => state.setGradientType(i ?? state.activeLayerIndex, type),
|
|
2237
|
+
applyColor: (str) => state.applyColor(str),
|
|
2238
|
+
grabColor: () => state.grabColor(),
|
|
2239
|
+
|
|
2240
|
+
// Setters (mirror `.set-*` modifiers)
|
|
2241
|
+
setHue: (v) => state.setHue(v),
|
|
2242
|
+
setAlpha: (v) => state.setAlpha(v),
|
|
2243
|
+
setAlphaValue: (percent) => state.setAlphaValue(percent),
|
|
2244
|
+
setColorSpace: (fmt) => state.setColorSpace(fmt),
|
|
2245
|
+
setColorValue: (str) => state.setColorValue(str),
|
|
2246
|
+
setAngle: (i, deg) => state.setAngle(i ?? state.activeLayerIndex, deg),
|
|
2247
|
+
setGradientValue: (str) => state.setGradientValue(str),
|
|
2248
|
+
|
|
2249
|
+
// Selection / state helpers
|
|
2250
|
+
selectStop: (li, si) => state.selectStop(li, si),
|
|
2251
|
+
toggleStop: (li, si) => state.toggleStop(li, si),
|
|
2252
|
+
setFromString: (str) => state.setFromString(str),
|
|
2253
|
+
toFormattedString: () => state.toFormattedString(),
|
|
2254
|
+
toHex: () => state.toHex(),
|
|
2255
|
+
|
|
2256
|
+
// Library helpers (global to the plugin; same for every picker)
|
|
2257
|
+
get recent() { return _recentStore.list; },
|
|
2258
|
+
clearRecent: () => clearRecent(),
|
|
2259
|
+
removeRecent: (v) => removeRecent(v),
|
|
2260
|
+
pushRecent: (v) => pushRecent(v),
|
|
2261
|
+
get presets() { return { tailwind: buildTailwindPreset(), ios: buildIosPreset() }; },
|
|
2262
|
+
};
|
|
2263
|
+
|
|
2264
|
+
return state;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
// ---- Helpers for finding in-clone elements ----
|
|
2268
|
+
|
|
2269
|
+
function findInClone(root, modifier) {
|
|
2270
|
+
// Match any `x-colorpicker.<modifier>` (possibly with additional modifiers or a value)
|
|
2271
|
+
const attrName = 'x-colorpicker.' + modifier;
|
|
2272
|
+
if (root.hasAttribute && root.hasAttribute(attrName)) return root;
|
|
2273
|
+
return root.querySelector(`[${cssEscapeAttr(attrName)}]`);
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
function cssEscapeAttr(name) {
|
|
2277
|
+
return name.replace(/\./g, '\\.');
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
// Rewrite x-dropdown* trigger attributes and the matching <menu id="..."> inside
|
|
2281
|
+
// a cloned subtree so multiple clones don't share the same popover ID.
|
|
2282
|
+
function uniquifyDropdownIdsIn(root, suffix) {
|
|
2283
|
+
const attrs = ['x-dropdown', 'x-dropdown.context', 'x-dropdown.hover'];
|
|
2284
|
+
for (const attr of attrs) {
|
|
2285
|
+
const selector = '[' + attr.replace(/\./g, '\\.') + ']';
|
|
2286
|
+
const triggers = root.querySelectorAll(selector);
|
|
2287
|
+
for (const trigger of triggers) {
|
|
2288
|
+
const original = trigger.getAttribute(attr);
|
|
2289
|
+
if (!original || /[`${}]/.test(original)) continue; // skip Alpine template literals
|
|
2290
|
+
// Only rewrite if we can find the target menu INSIDE this clone
|
|
2291
|
+
let menu = null;
|
|
2292
|
+
try { menu = root.querySelector('#' + CSS.escape(original)); } catch {}
|
|
2293
|
+
if (!menu) continue;
|
|
2294
|
+
const newId = original + '--' + suffix;
|
|
2295
|
+
trigger.setAttribute(attr, newId);
|
|
2296
|
+
menu.id = newId;
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// Coalesce rapid-fire calls (pointermove, input) into at most one per animation frame.
|
|
2302
|
+
// The latest args win. Essential for keeping the main thread responsive on busy devices.
|
|
2303
|
+
function rafThrottle(fn) {
|
|
2304
|
+
let scheduled = false;
|
|
2305
|
+
let lastArgs;
|
|
2306
|
+
let lastThis;
|
|
2307
|
+
return function throttled(...args) {
|
|
2308
|
+
lastArgs = args;
|
|
2309
|
+
lastThis = this;
|
|
2310
|
+
if (scheduled) return;
|
|
2311
|
+
scheduled = true;
|
|
2312
|
+
requestAnimationFrame(() => {
|
|
2313
|
+
scheduled = false;
|
|
2314
|
+
fn.apply(lastThis, lastArgs);
|
|
2315
|
+
});
|
|
2316
|
+
};
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
// JSON-serialize a value for use in an Alpine x-data attribute via setAttribute.
|
|
2320
|
+
// setAttribute does NOT decode HTML entities, so no escaping needed — JSON is
|
|
2321
|
+
// already valid JS literal syntax that Alpine can parse directly.
|
|
2322
|
+
function _jsonStringifyForAlpine(v) {
|
|
2323
|
+
try { return JSON.stringify(v); } catch { return '{}'; }
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
// Evaluate an expression in the scope of a given element, outside a directive context
|
|
2327
|
+
function evaluateLaterShim(el, expression) {
|
|
2328
|
+
if (!window.Alpine?.evaluateLater) {
|
|
2329
|
+
return (cb) => { try { cb(new Function('return ' + expression)()); } catch { cb(null); } };
|
|
2330
|
+
}
|
|
2331
|
+
return Alpine.evaluateLater(el, expression);
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
function findAncestorState(el) {
|
|
2335
|
+
let n = el;
|
|
2336
|
+
while (n) {
|
|
2337
|
+
if (n._colorpickerState) return n._colorpickerState;
|
|
2338
|
+
n = n.parentElement;
|
|
2339
|
+
}
|
|
2340
|
+
return null;
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
function findLayerContext(el) {
|
|
2344
|
+
let n = el;
|
|
2345
|
+
while (n) {
|
|
2346
|
+
if (n.hasAttribute && n.hasAttribute('data-cp-layer-clone')) {
|
|
2347
|
+
return { layerIndex: n._cpLayerIndex, cloneRoot: n };
|
|
2348
|
+
}
|
|
2349
|
+
n = n.parentElement;
|
|
2350
|
+
}
|
|
2351
|
+
return null;
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
// ---- Directive registration ----
|
|
2355
|
+
|
|
2356
|
+
function registerPlugin() {
|
|
2357
|
+
if (!window.Alpine || typeof Alpine.directive !== 'function') return;
|
|
2358
|
+
|
|
2359
|
+
Alpine.directive('colorpicker', (el, { modifiers, expression }, { cleanup, evaluateLater }) => {
|
|
2360
|
+
// Root: no modifiers
|
|
2361
|
+
if (!modifiers || modifiers.length === 0) {
|
|
2362
|
+
// <template x-colorpicker> → registered as the page-wide default
|
|
2363
|
+
// override. Every bare swatch (`<button x-colorpicker.swatch>`) that
|
|
2364
|
+
// would otherwise auto-create an empty popover instead clones from
|
|
2365
|
+
// this template. Only one default per page; first declaration wins.
|
|
2366
|
+
if (el.tagName === 'TEMPLATE') {
|
|
2367
|
+
if (expression || el.id) {
|
|
2368
|
+
// Id-keyed templates are no longer supported — declare the
|
|
2369
|
+
// picker inline (`<menu id="X" popover x-colorpicker>`) or
|
|
2370
|
+
// wrap it in a Manifest HTML component for reuse.
|
|
2371
|
+
try { console.warn('[colorpicker] Id-keyed <template x-colorpicker> is no longer supported. Use a live inline element with the same id, or wrap the picker in an HTML component.'); } catch {}
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
registerDefaultColorpickerTemplate(el);
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
const state = createPickerState(el);
|
|
2379
|
+
el._colorpickerState = state;
|
|
2380
|
+
|
|
2381
|
+
// Panel-list expression: `x-colorpicker="['solid', 'gradient']"`.
|
|
2382
|
+
// Parsed once here and used by _injectDefaultUI to filter + reorder
|
|
2383
|
+
// the default UI's tabs and panels. Anything else is ignored.
|
|
2384
|
+
state.allowedPanels = parsePanelsExpression(expression);
|
|
2385
|
+
|
|
2386
|
+
// Find the form-participation input
|
|
2387
|
+
state.hiddenInput = el.querySelector('input[type=color], input[type=hidden]');
|
|
2388
|
+
|
|
2389
|
+
// Find trigger button (any button with x-dropdown pointing to this element's ID)
|
|
2390
|
+
const id = el.id;
|
|
2391
|
+
state.triggerBtn = id ? document.querySelector(`[x-dropdown="${id}"]`) : null;
|
|
2392
|
+
if (state.triggerBtn) {
|
|
2393
|
+
state.triggerBtn.addEventListener('click', () => {
|
|
2394
|
+
requestAnimationFrame(() => state.syncUI());
|
|
2395
|
+
});
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
// Defer mount so all child directives have had a chance to register.
|
|
2399
|
+
// setTimeout (rather than rAF) so we still fire when the tab is
|
|
2400
|
+
// backgrounded or rAF is throttled — mount must happen for the
|
|
2401
|
+
// picker to work, and it doesn't need to be sync'd with paint.
|
|
2402
|
+
setTimeout(() => state.mount(), 0);
|
|
2403
|
+
|
|
2404
|
+
cleanup(() => {
|
|
2405
|
+
if (el.id) delete _pickerRegistry[el.id];
|
|
2406
|
+
delete el._colorpickerState;
|
|
2407
|
+
});
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
// Child hook
|
|
2412
|
+
const role = modifiers[0];
|
|
2413
|
+
|
|
2414
|
+
// Swatches work as triggers OUTSIDE any picker — handle before the ancestor check.
|
|
2415
|
+
// We only assign IDs + generate the popover tag; the dropdown plugin (x-dropdown)
|
|
2416
|
+
// owns popover mechanics, anchor positioning, and transitions.
|
|
2417
|
+
if (role === 'swatch') {
|
|
2418
|
+
if (el._cpSwatchWired) return; // guard against re-firing via initTree
|
|
2419
|
+
el._cpSwatchWired = true;
|
|
2420
|
+
|
|
2421
|
+
// ---- Optional x-model binding ----
|
|
2422
|
+
// When a swatch carries x-model, the expression is the source of truth for
|
|
2423
|
+
// that swatch's color. The plugin:
|
|
2424
|
+
// • reactively shows the model value as the swatch background (via CSS var)
|
|
2425
|
+
// • exposes a read accessor the picker uses on open to load the value
|
|
2426
|
+
// • exposes a write accessor the picker uses on close to persist changes
|
|
2427
|
+
// The dev can still apply inline style / class overrides on top.
|
|
2428
|
+
const modelExpr = el.getAttribute('x-model');
|
|
2429
|
+
if (modelExpr && window.Alpine?.evaluateLater && window.Alpine?.effect) {
|
|
2430
|
+
try {
|
|
2431
|
+
const readFn = Alpine.evaluateLater(el, modelExpr);
|
|
2432
|
+
// Reactive background preview
|
|
2433
|
+
Alpine.effect(() => {
|
|
2434
|
+
readFn(v => {
|
|
2435
|
+
if (typeof v === 'string' && v.length) {
|
|
2436
|
+
el.style.setProperty('--color-picker-swatch', v);
|
|
2437
|
+
el.setAttribute('data-cp-model-value', v);
|
|
2438
|
+
}
|
|
2439
|
+
});
|
|
2440
|
+
});
|
|
2441
|
+
el._cpModelGetter = (cb) => readFn(cb);
|
|
2442
|
+
// Writer: evaluate `<modelExpr> = <JSON-stringified value>`. JSON.stringify
|
|
2443
|
+
// ensures the value is safely serialized (color strings + gradient CSS
|
|
2444
|
+
// are all JSON-safe).
|
|
2445
|
+
el._cpModelSetter = (v) => {
|
|
2446
|
+
try { Alpine.evaluate(el, `${modelExpr} = ${JSON.stringify(v)}`); } catch {}
|
|
2447
|
+
};
|
|
2448
|
+
} catch {}
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
// ---- Initial color via `value` attribute ----
|
|
2452
|
+
// The swatch can carry a `value="#abc123"` attribute (mirrors native
|
|
2453
|
+
// <input type="color"> semantics). It seeds the picker on first open
|
|
2454
|
+
// and the swatch's CSS var so the border-color derivation paints
|
|
2455
|
+
// correctly before any interaction.
|
|
2456
|
+
const valueAttr = el.getAttribute('value');
|
|
2457
|
+
if (valueAttr && !el.style.getPropertyValue('--color-picker-swatch')) {
|
|
2458
|
+
el.style.setProperty('--color-picker-swatch', valueAttr);
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
// ---- Form participation via `name` attribute ----
|
|
2462
|
+
// When the swatch has `name=`, the plugin synthesizes a sibling
|
|
2463
|
+
// <input type="hidden"> with that name (or adopts a matching one
|
|
2464
|
+
// already in the DOM). syncToInput then writes the picker's hex
|
|
2465
|
+
// value to it, dispatching input/change events for form code.
|
|
2466
|
+
// No `name` → no synthesized input — purely decorative swatch.
|
|
2467
|
+
const nameAttr = el.getAttribute('name');
|
|
2468
|
+
if (nameAttr) {
|
|
2469
|
+
let hidden = el.parentElement?.querySelector?.(
|
|
2470
|
+
`:scope > input[type=hidden][name="${nameAttr.replace(/"/g, '\\"')}"]`
|
|
2471
|
+
);
|
|
2472
|
+
if (!hidden) {
|
|
2473
|
+
hidden = document.createElement('input');
|
|
2474
|
+
hidden.type = 'hidden';
|
|
2475
|
+
hidden.name = nameAttr;
|
|
2476
|
+
hidden.value = valueAttr || '';
|
|
2477
|
+
el.after(hidden);
|
|
2478
|
+
el._cpSynthesizedHidden = hidden;
|
|
2479
|
+
}
|
|
2480
|
+
el._cpHiddenInput = hidden;
|
|
2481
|
+
// Drop `name` from the swatch itself so the form doesn't pick up
|
|
2482
|
+
// both the (typically empty) button and the hidden input.
|
|
2483
|
+
if (el.tagName === 'BUTTON') el.removeAttribute('name');
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
cleanup(() => {
|
|
2487
|
+
if (el._cpSynthesizedHidden && el._cpSynthesizedHidden.isConnected) {
|
|
2488
|
+
el._cpSynthesizedHidden.remove();
|
|
2489
|
+
}
|
|
2490
|
+
});
|
|
2491
|
+
|
|
2492
|
+
const wireSwatchTo = (target) => {
|
|
2493
|
+
if (!target) return;
|
|
2494
|
+
|
|
2495
|
+
const isDialog = target.tagName === 'DIALOG';
|
|
2496
|
+
|
|
2497
|
+
// For non-dialog popover targets (menu / div with popover), delegate
|
|
2498
|
+
// open/close + anchor positioning to the dropdowns plugin so the
|
|
2499
|
+
// picker appears anchored to the swatch like a dropdown menu.
|
|
2500
|
+
// <dialog> targets are NOT routed through x-dropdown — dialogs are
|
|
2501
|
+
// modal/centered surfaces, not anchored to a trigger. We open them
|
|
2502
|
+
// imperatively on click via showPopover() or showModal().
|
|
2503
|
+
if (!isDialog
|
|
2504
|
+
&& target.hasAttribute('popover')
|
|
2505
|
+
&& !el.hasAttribute('popovertarget')
|
|
2506
|
+
&& !el.hasAttribute('x-dropdown')) {
|
|
2507
|
+
el.setAttribute('x-dropdown', target.id);
|
|
2508
|
+
if (window.Alpine?.initTree) Alpine.initTree(el);
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
// Retarget the picker to this swatch on click (load its color + point writes here)
|
|
2512
|
+
el.addEventListener('click', (e) => {
|
|
2513
|
+
// Open dialogs imperatively. Prefer popover semantics when the
|
|
2514
|
+
// dialog has a `popover` attribute (light-dismiss); otherwise
|
|
2515
|
+
// open as a true modal with backdrop and focus trap.
|
|
2516
|
+
if (isDialog) {
|
|
2517
|
+
e.preventDefault();
|
|
2518
|
+
try {
|
|
2519
|
+
if (target.hasAttribute('popover')) {
|
|
2520
|
+
if (!target.matches(':popover-open')) target.showPopover();
|
|
2521
|
+
} else {
|
|
2522
|
+
if (!target.open) target.showModal();
|
|
2523
|
+
}
|
|
2524
|
+
} catch {}
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
const retarget = () => {
|
|
2528
|
+
// Picker state may live on `target` itself (e.g. <menu x-colorpicker>)
|
|
2529
|
+
// or on a descendant when the target is a wrapping container such
|
|
2530
|
+
// as <dialog> hosting <div x-colorpicker> inside.
|
|
2531
|
+
let st = target._colorpickerState;
|
|
2532
|
+
if (!st) {
|
|
2533
|
+
const inner = target.querySelector?.('[x-colorpicker]');
|
|
2534
|
+
if (inner && inner._colorpickerState) st = inner._colorpickerState;
|
|
2535
|
+
}
|
|
2536
|
+
if (!st) { setTimeout(retarget, 0); return; }
|
|
2537
|
+
st.triggerBtn = el;
|
|
2538
|
+
// Route form-participation writes to this swatch's hidden
|
|
2539
|
+
// input (synthesized from `name`, or dev-supplied sibling).
|
|
2540
|
+
// Falls back to whatever the picker container already had
|
|
2541
|
+
// — preserves the existing inline-input flow.
|
|
2542
|
+
if (el._cpHiddenInput) st.hiddenInput = el._cpHiddenInput;
|
|
2543
|
+
// Load the trigger's current value. Priority:
|
|
2544
|
+
// 1. x-model getter (shared-picker flow)
|
|
2545
|
+
// 2. paired hidden input value (form-participation flow)
|
|
2546
|
+
// 3. `value` attribute on the swatch
|
|
2547
|
+
// 4. --color-picker-swatch CSS var (auto / inline-style flow)
|
|
2548
|
+
// 5. fallback '#000000'
|
|
2549
|
+
if (el._cpModelGetter) {
|
|
2550
|
+
el._cpModelGetter(v => { if (typeof v === 'string' && v.length) st.setFromString(v); });
|
|
2551
|
+
} else {
|
|
2552
|
+
const current = (el._cpHiddenInput && el._cpHiddenInput.value)
|
|
2553
|
+
|| el.getAttribute('value')
|
|
2554
|
+
|| el.style.getPropertyValue('--color-picker-swatch')
|
|
2555
|
+
|| getComputedStyle(el).getPropertyValue('--color-picker-swatch').trim()
|
|
2556
|
+
|| '#000000';
|
|
2557
|
+
if (current) st.setFromString(current);
|
|
2558
|
+
}
|
|
2559
|
+
// Defer heavy UI sync so the popover's entry transition runs unimpeded
|
|
2560
|
+
setTimeout(() => st.syncUI(), 0);
|
|
2561
|
+
};
|
|
2562
|
+
retarget();
|
|
2563
|
+
});
|
|
2564
|
+
};
|
|
2565
|
+
|
|
2566
|
+
// Panel-list expression on a swatch (`x-colorpicker.swatch="['solid']"`)
|
|
2567
|
+
// → not an id, not a template literal — auto-create a popover and pass
|
|
2568
|
+
// the panels through so its picker UI is filtered to that subset.
|
|
2569
|
+
const panelsExpr = parsePanelsExpression(expression);
|
|
2570
|
+
|
|
2571
|
+
if (!expression) {
|
|
2572
|
+
// Bare swatch → auto-create popover with generated ID
|
|
2573
|
+
wireSwatchTo(createSwatchPopover());
|
|
2574
|
+
} else if (panelsExpr) {
|
|
2575
|
+
// Panel list → auto-create popover with the expression preserved
|
|
2576
|
+
wireSwatchTo(createSwatchPopover(undefined, expression));
|
|
2577
|
+
} else if (expression.includes('${') || expression.includes('`')) {
|
|
2578
|
+
// Alpine template literal → resolve, then look up or auto-create
|
|
2579
|
+
const evaluator = evaluateLater(expression);
|
|
2580
|
+
evaluator(val => {
|
|
2581
|
+
if (!val) return;
|
|
2582
|
+
const t = resolvePickerById(val) || createSwatchPopover(val);
|
|
2583
|
+
wireSwatchTo(t);
|
|
2584
|
+
});
|
|
2585
|
+
} else {
|
|
2586
|
+
// Static ID → resolve via inline / template / auto
|
|
2587
|
+
const t = resolvePickerById(expression) || createSwatchPopover(expression);
|
|
2588
|
+
wireSwatchTo(t);
|
|
2589
|
+
}
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// All other child hooks require an ancestor picker state
|
|
2594
|
+
const state = findAncestorState(el);
|
|
2595
|
+
if (!state) return;
|
|
2596
|
+
|
|
2597
|
+
switch (role) {
|
|
2598
|
+
case 'solid':
|
|
2599
|
+
if (el.tagName === 'TEMPLATE') {
|
|
2600
|
+
state.solidTemplate = el;
|
|
2601
|
+
} else if (!findLayerContext(el)) {
|
|
2602
|
+
// Top-level instance (e.g. Solid tab). Accordion instances
|
|
2603
|
+
// inside a layer clone are handled by renderLayers directly.
|
|
2604
|
+
state.solidInstances.push(el);
|
|
2605
|
+
}
|
|
2606
|
+
return;
|
|
2607
|
+
|
|
2608
|
+
case 'layer-options':
|
|
2609
|
+
if (el.tagName === 'TEMPLATE') state.layerTemplate = el;
|
|
2610
|
+
return;
|
|
2611
|
+
|
|
2612
|
+
case 'gradient':
|
|
2613
|
+
if (el.tagName === 'TEMPLATE') state.gradientTemplate = el;
|
|
2614
|
+
else state.gradientInstances.push(el);
|
|
2615
|
+
return;
|
|
2616
|
+
|
|
2617
|
+
case 'gradient-layers':
|
|
2618
|
+
state.layersContainer = el;
|
|
2619
|
+
return;
|
|
2620
|
+
|
|
2621
|
+
case 'layer-stops-bar':
|
|
2622
|
+
// Handled per-clone in _renderStopBar
|
|
2623
|
+
return;
|
|
2624
|
+
|
|
2625
|
+
// Actions
|
|
2626
|
+
case 'add-layer':
|
|
2627
|
+
el.addEventListener('click', () => state.addLayer());
|
|
2628
|
+
return;
|
|
2629
|
+
|
|
2630
|
+
case 'grab-color':
|
|
2631
|
+
el.addEventListener('click', () => state.grabColor());
|
|
2632
|
+
return;
|
|
2633
|
+
|
|
2634
|
+
case 'apply-color': {
|
|
2635
|
+
el.addEventListener('click', () => {
|
|
2636
|
+
// Respect disabled attribute (used when the swatch is a gradient
|
|
2637
|
+
// and the user is editing a gradient stop — CSS doesn't allow
|
|
2638
|
+
// gradients as color stop values).
|
|
2639
|
+
if (el.hasAttribute('disabled')) return;
|
|
2640
|
+
const cs = window.getComputedStyle(el);
|
|
2641
|
+
const raw = el.style.background || el.style.backgroundColor || cs.backgroundColor;
|
|
2642
|
+
if (!raw) return;
|
|
2643
|
+
// Library-swatch clicks are marked so the commit cycle knows not
|
|
2644
|
+
// to record them as "recent" even if the picker closes afterwards.
|
|
2645
|
+
const fromLibrary = !!el.closest('[x-data*="swatch:"]');
|
|
2646
|
+
const fromStopMenu = !!el.closest('menu[id^="stop-context-menu"]');
|
|
2647
|
+
state._markUserChange(fromLibrary);
|
|
2648
|
+
|
|
2649
|
+
// Top-level library picks (NOT inside a stop-context-menu) replace
|
|
2650
|
+
// the WHOLE field — switch picker mode to match the swatch's value
|
|
2651
|
+
// type so a solid swatch doesn't become a stop in an existing
|
|
2652
|
+
// gradient and a gradient swatch doesn't get parsed as the active
|
|
2653
|
+
// stop's color. Stop-menu picks intentionally write to the right-
|
|
2654
|
+
// clicked stop and stay in gradient mode.
|
|
2655
|
+
// Use _setPickerMode (not _switchToSolidMode) — we don't want to
|
|
2656
|
+
// flip the user off whatever tab they're on (typically Library).
|
|
2657
|
+
const valueIsGradient = raw.includes('gradient(');
|
|
2658
|
+
if (fromLibrary && !fromStopMenu) {
|
|
2659
|
+
state._setPickerMode(valueIsGradient ? 'gradient' : 'solid');
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
state.applyColor(raw);
|
|
2663
|
+
});
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
case 'remove-recent': {
|
|
2668
|
+
// Expected placement: a menu item inside a <menu popover> referenced by
|
|
2669
|
+
// x-dropdown.context on a Recent swatch. The dropdowns plugin stashes the
|
|
2670
|
+
// triggering element on `menu._triggerEl`. We read its `data-cp-value`
|
|
2671
|
+
// (the raw stored form) and remove that entry from the Recent cookie.
|
|
2672
|
+
el.addEventListener('click', () => {
|
|
2673
|
+
const menu = el.closest('[popover]');
|
|
2674
|
+
const trigger = menu?._triggerEl || menu?._triggerHost;
|
|
2675
|
+
const value = trigger?.getAttribute?.('data-cp-value');
|
|
2676
|
+
if (value) removeRecent(value);
|
|
2677
|
+
});
|
|
2678
|
+
return;
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
case 'duplicate-layer':
|
|
2682
|
+
case 'remove-layer':
|
|
2683
|
+
case 'flip-layer':
|
|
2684
|
+
case 'rotate-layer':
|
|
2685
|
+
case 'add-layer-above':
|
|
2686
|
+
case 'add-layer-below':
|
|
2687
|
+
case 'move-layer-up':
|
|
2688
|
+
case 'move-layer-down':
|
|
2689
|
+
case 'duplicate-stop':
|
|
2690
|
+
case 'delete-stop':
|
|
2691
|
+
case 'set-gradient-type': {
|
|
2692
|
+
el.addEventListener('click', () => {
|
|
2693
|
+
// Respect :disabled bindings — Alpine toggles the attribute on the
|
|
2694
|
+
// element; a disabled menu item shouldn't fire its action.
|
|
2695
|
+
if (el.hasAttribute('disabled')) return;
|
|
2696
|
+
const ctx = findLayerContext(el);
|
|
2697
|
+
const li = ctx ? ctx.layerIndex : state.activeLayerIndex;
|
|
2698
|
+
switch (role) {
|
|
2699
|
+
case 'duplicate-layer': state.duplicateLayer(li); break;
|
|
2700
|
+
case 'remove-layer': state.removeLayer(li); break;
|
|
2701
|
+
case 'flip-layer': state.flipLayer(li); break;
|
|
2702
|
+
case 'rotate-layer': state.rotateLayer(li); break;
|
|
2703
|
+
case 'add-layer-above': state.addLayerAt(li, 'above'); break;
|
|
2704
|
+
case 'add-layer-below': state.addLayerAt(li, 'below'); break;
|
|
2705
|
+
case 'move-layer-up': state.moveLayer(li, -1); break;
|
|
2706
|
+
case 'move-layer-down': state.moveLayer(li, +1); break;
|
|
2707
|
+
case 'duplicate-stop': state.duplicateStop(li, state.activeStopIndex); break;
|
|
2708
|
+
case 'delete-stop': state.deleteStop(li, state.activeStopIndex); break;
|
|
2709
|
+
case 'set-gradient-type': {
|
|
2710
|
+
// Value comes from the expression (Alpine parsed) or the attribute
|
|
2711
|
+
const type = expression || el.getAttribute('x-colorpicker.set-gradient-type');
|
|
2712
|
+
state.setGradientType(li, (type || '').replace(/['"]/g, ''));
|
|
2713
|
+
break;
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
// Inputs inside layer (angle)
|
|
2721
|
+
case 'set-angle': {
|
|
2722
|
+
// Input listener
|
|
2723
|
+
el.addEventListener('input', () => {
|
|
2724
|
+
const ctx = findLayerContext(el);
|
|
2725
|
+
const li = ctx ? ctx.layerIndex : state.activeLayerIndex;
|
|
2726
|
+
state.setAngle(li, parseFloat(el.value) || 0);
|
|
2727
|
+
});
|
|
2728
|
+
// Drag-scrub
|
|
2729
|
+
let scrubbing = false, scrubStartX = 0, scrubStartAngle = 0, scrubLi = 0;
|
|
2730
|
+
el.addEventListener('pointerdown', (e) => {
|
|
2731
|
+
if (document.activeElement === el) return;
|
|
2732
|
+
e.preventDefault();
|
|
2733
|
+
const ctx = findLayerContext(el);
|
|
2734
|
+
scrubLi = ctx ? ctx.layerIndex : state.activeLayerIndex;
|
|
2735
|
+
scrubbing = true;
|
|
2736
|
+
scrubStartX = e.clientX;
|
|
2737
|
+
scrubStartAngle = state.layers[scrubLi]?.angle || 0;
|
|
2738
|
+
el.setPointerCapture(e.pointerId);
|
|
2739
|
+
});
|
|
2740
|
+
const applyScrub = (e) => {
|
|
2741
|
+
const newAngle = scrubStartAngle + (e.clientX - scrubStartX);
|
|
2742
|
+
state.setAngle(scrubLi, Math.round(newAngle));
|
|
2743
|
+
el.value = state.layers[scrubLi]?.angle || 0;
|
|
2744
|
+
};
|
|
2745
|
+
const throttledScrub = rafThrottle(applyScrub);
|
|
2746
|
+
el.addEventListener('pointermove', (e) => {
|
|
2747
|
+
if (!scrubbing) return;
|
|
2748
|
+
throttledScrub(e);
|
|
2749
|
+
});
|
|
2750
|
+
el.addEventListener('pointerup', (e) => {
|
|
2751
|
+
if (scrubbing) {
|
|
2752
|
+
scrubbing = false;
|
|
2753
|
+
if (Math.abs(e.clientX - scrubStartX) < 3) { el.focus(); el.select(); }
|
|
2754
|
+
}
|
|
2755
|
+
});
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
// Gradient CSS textarea
|
|
2760
|
+
case 'set-gradient-value':
|
|
2761
|
+
state.gradientValueInputs.push(el);
|
|
2762
|
+
el.addEventListener('input', () => {
|
|
2763
|
+
state.setGradientValue(el.value);
|
|
2764
|
+
});
|
|
2765
|
+
return;
|
|
2766
|
+
|
|
2767
|
+
// Solid-tab controls (wired via _wireSolidControls when mounted)
|
|
2768
|
+
// If the developer places them OUTSIDE a solid-panel instance, wire individually:
|
|
2769
|
+
case 'set-canvas':
|
|
2770
|
+
case 'set-hue':
|
|
2771
|
+
case 'set-alpha':
|
|
2772
|
+
case 'set-alpha-value':
|
|
2773
|
+
case 'set-color-value': {
|
|
2774
|
+
// These are typically inside a solid-panel template/instance.
|
|
2775
|
+
// (The usual path handles them inside _mountSolidInstance.)
|
|
2776
|
+
return;
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
// set-color-space supports two roles:
|
|
2780
|
+
// • With expression (`<li x-colorpicker.set-color-space="hex">`) →
|
|
2781
|
+
// click sets that format. Tracked so we can toggle .active on the
|
|
2782
|
+
// current choice.
|
|
2783
|
+
// • Without expression (`<button x-colorpicker.set-color-space>`) →
|
|
2784
|
+
// reactive label whose text reflects the active format. Useful as
|
|
2785
|
+
// a dropdown trigger that shows the current format.
|
|
2786
|
+
// • <select x-colorpicker.set-color-space> still works through the
|
|
2787
|
+
// legacy _wireSolidControls flow inside a solid-panel instance.
|
|
2788
|
+
case 'set-color-space': {
|
|
2789
|
+
if (el.tagName === 'SELECT') return; // legacy flow handles it
|
|
2790
|
+
const raw = (expression || '').replace(/['"`]/g, '').trim().toLowerCase();
|
|
2791
|
+
if (raw) {
|
|
2792
|
+
// Choice element — click selects this format
|
|
2793
|
+
state.formatChoiceEls.push({ el, fmt: raw });
|
|
2794
|
+
el.addEventListener('click', () => {
|
|
2795
|
+
if (el.hasAttribute('disabled')) return;
|
|
2796
|
+
state.setColorSpace(raw);
|
|
2797
|
+
});
|
|
2798
|
+
} else {
|
|
2799
|
+
// Label element — text reflects current format
|
|
2800
|
+
state.formatLabelEls.push(el);
|
|
2801
|
+
}
|
|
2802
|
+
state._refreshFormatLabels();
|
|
2803
|
+
return;
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
// ---- Library ----
|
|
2807
|
+
|
|
2808
|
+
case 'library': {
|
|
2809
|
+
if (el.tagName === 'TEMPLATE') {
|
|
2810
|
+
// Dev-defined library layout — cloned into the container at render time.
|
|
2811
|
+
// Nested <template x-colorpicker.library-group/palette/swatch> are resolved
|
|
2812
|
+
// in-place during rendering (x-for style).
|
|
2813
|
+
state.libraryTemplate = el;
|
|
2814
|
+
} else {
|
|
2815
|
+
// Container where the library renders. Multiple containers are supported
|
|
2816
|
+
// (e.g., the primary tab AND inline menus like stop-context-menu); each
|
|
2817
|
+
// receives an independent clone of the library template. The expression
|
|
2818
|
+
// from the FIRST container wins as the data source; others reuse it.
|
|
2819
|
+
if (!state.libraryContainers.includes(el)) {
|
|
2820
|
+
state.libraryContainers.push(el);
|
|
2821
|
+
// If the picker is already mounted, render into ONLY this new
|
|
2822
|
+
// container so other containers' in-flight directive inits
|
|
2823
|
+
// (x-dropdown.context menu lookups, tooltips, etc.) aren't
|
|
2824
|
+
// torn down mid-init.
|
|
2825
|
+
if (state._mounted) state._renderIntoContainer(el);
|
|
2826
|
+
}
|
|
2827
|
+
if (expression && !state.libraryRootValue) state.libraryRootValue = expression;
|
|
2828
|
+
}
|
|
2829
|
+
return;
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
case 'library-group':
|
|
2833
|
+
case 'library-palette':
|
|
2834
|
+
case 'library-swatch':
|
|
2835
|
+
case 'library-recent-swatch': {
|
|
2836
|
+
// Nested templates — no registration. Resolved via querySelector at render time
|
|
2837
|
+
// (so their position in the HTML determines where clones land).
|
|
2838
|
+
// `library-recent-swatch` is an optional alternate template used only for
|
|
2839
|
+
// swatches inside the Recent group (typically wires up x-dropdown.context).
|
|
2840
|
+
return;
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
}
|
|
2844
|
+
});
|
|
2845
|
+
|
|
2846
|
+
// $colorpicker — accessor that is both callable (`$colorpicker('id')`) and property-readable
|
|
2847
|
+
// (`$colorpicker.hex` — uses nearest ancestor picker).
|
|
2848
|
+
Alpine.magic('colorpicker', (el) => {
|
|
2849
|
+
const localState = findAncestorState(el);
|
|
2850
|
+
const localApi = localState?.api || null;
|
|
2851
|
+
|
|
2852
|
+
// Function form: `$colorpicker('picker-id')` → that picker's API.
|
|
2853
|
+
// Reads from the reactive registry so bindings resolve even when the
|
|
2854
|
+
// picker is declared later in the DOM than its consumer.
|
|
2855
|
+
const byId = (id) => {
|
|
2856
|
+
if (!id) return localApi;
|
|
2857
|
+
// Reactive read: tracks the key even if not yet registered
|
|
2858
|
+
const api = _pickerRegistry[id];
|
|
2859
|
+
if (api) return api;
|
|
2860
|
+
// Allow lookup by swatch button ID (resolve through its popovertarget)
|
|
2861
|
+
const el2 = document.getElementById(id);
|
|
2862
|
+
if (el2 && el2.hasAttribute('popovertarget')) {
|
|
2863
|
+
const popoverId = el2.getAttribute('popovertarget');
|
|
2864
|
+
const api2 = _pickerRegistry[popoverId];
|
|
2865
|
+
if (api2) return api2;
|
|
2866
|
+
}
|
|
2867
|
+
return _nullApi;
|
|
2868
|
+
};
|
|
2869
|
+
|
|
2870
|
+
return new Proxy(byId, {
|
|
2871
|
+
get(fn, prop) {
|
|
2872
|
+
// Coerce `${$colorpicker}` (no call) to the local picker's CSS string
|
|
2873
|
+
if (prop === Symbol.toPrimitive || prop === 'toString' || prop === 'valueOf') {
|
|
2874
|
+
return () => (localApi ? localApi.css : '');
|
|
2875
|
+
}
|
|
2876
|
+
// Global helpers (picker-agnostic) — useful for library composition.
|
|
2877
|
+
// Each is BOTH callable AND spreadable:
|
|
2878
|
+
// {...$colorpicker.tailwind} → default English preset
|
|
2879
|
+
// $colorpicker.tailwind(labels) → localized preset (same values, translated names)
|
|
2880
|
+
// {...$colorpicker.tailwind(labels)} → spread the localized result
|
|
2881
|
+
if (prop === 'presets') return _makeCallablePreset(buildDefaultLibrary);
|
|
2882
|
+
if (prop === 'tailwind') return _makeCallablePreset(buildTailwindPreset);
|
|
2883
|
+
if (prop === 'ios') return _makeCallablePreset(buildIosPreset);
|
|
2884
|
+
if (prop === 'recent') return _recentStore.list.slice(0, _recentMax);
|
|
2885
|
+
if (localApi && prop in localApi) return localApi[prop];
|
|
2886
|
+
return fn[prop];
|
|
2887
|
+
},
|
|
2888
|
+
has(fn, prop) {
|
|
2889
|
+
if (prop === 'presets' || prop === 'tailwind' || prop === 'ios' || prop === 'recent') return true;
|
|
2890
|
+
return (localApi && prop in localApi) || prop in fn;
|
|
2891
|
+
}
|
|
2892
|
+
});
|
|
2893
|
+
});
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
// ---- Picker resolution: inline / default-template / auto-generated ----
|
|
2897
|
+
//
|
|
2898
|
+
// Two ways a dev can declare a picker:
|
|
2899
|
+
// 1. Live inline element: <menu id="brand-picker" popover x-colorpicker>…</menu>
|
|
2900
|
+
// <dialog id="brand-picker" x-colorpicker>…</dialog>
|
|
2901
|
+
// <div id="brand-picker" x-colorpicker>…</div>
|
|
2902
|
+
// 2. Auto-created (bare): <button x-colorpicker.swatch> generates its own popover.
|
|
2903
|
+
// By default, the popover is filled with the plugin's
|
|
2904
|
+
// hardcoded fallback UI. Devs can override it page-wide
|
|
2905
|
+
// by adding a single `<template x-colorpicker>` (no id)
|
|
2906
|
+
// anywhere in the markup — the auto-creator clones from
|
|
2907
|
+
// that instead.
|
|
2908
|
+
//
|
|
2909
|
+
// For "componentize and reuse" use cases that previously needed an id-keyed template,
|
|
2910
|
+
// wrap the picker in a Manifest HTML component (`<x-my-picker>`) and drop it wherever
|
|
2911
|
+
// it's needed. This keeps the plugin's resolution model deliberately small.
|
|
2912
|
+
|
|
2913
|
+
let _defaultColorpickerTemplate = null; // <template x-colorpicker> (no id)
|
|
2914
|
+
|
|
2915
|
+
function registerDefaultColorpickerTemplate(tpl) {
|
|
2916
|
+
// First wins. Subsequent declarations are ignored — explicit, no surprises.
|
|
2917
|
+
if (!tpl || _defaultColorpickerTemplate) return;
|
|
2918
|
+
_defaultColorpickerTemplate = tpl;
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
// Resolve a swatch's target by id. Inline elements only — templates are never
|
|
2922
|
+
// looked up by id anymore (use a `<menu id="X" x-colorpicker>` or wrap in an
|
|
2923
|
+
// HTML component for that pattern).
|
|
2924
|
+
function resolvePickerById(id) {
|
|
2925
|
+
if (!id) return null;
|
|
2926
|
+
const live = document.getElementById(id);
|
|
2927
|
+
if (live && live.tagName !== 'TEMPLATE') return live;
|
|
2928
|
+
return null;
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
// ---- Auto-created fallback popover per swatch ----
|
|
2932
|
+
|
|
2933
|
+
let _swatchPopoverCounter = 0;
|
|
2934
|
+
function nextAutoSwatchId() {
|
|
2935
|
+
_swatchPopoverCounter++;
|
|
2936
|
+
while (document.getElementById('colorpicker-swatch-' + _swatchPopoverCounter)) _swatchPopoverCounter++;
|
|
2937
|
+
return 'colorpicker-swatch-' + _swatchPopoverCounter;
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
function createSwatchPopover(customId, panelsExpr) {
|
|
2941
|
+
const id = customId || nextAutoSwatchId();
|
|
2942
|
+
|
|
2943
|
+
// If a default `<template x-colorpicker>` is registered, clone its root
|
|
2944
|
+
// and use that as the swatch's popover. Preserves the dev's chosen
|
|
2945
|
+
// wrapper (menu / dialog / div) and any attributes they put on it.
|
|
2946
|
+
// The dev's content inside the template is rendered verbatim (mount's
|
|
2947
|
+
// noDeclared check sees real children and skips _injectDefaultUI).
|
|
2948
|
+
// If the template exists in the DOM but its directive hasn't fired yet
|
|
2949
|
+
// (source-order race), scan for it now so swatches earlier in the tree
|
|
2950
|
+
// still pick it up.
|
|
2951
|
+
if (!_defaultColorpickerTemplate) {
|
|
2952
|
+
const candidates = document.querySelectorAll('template[x-colorpicker]');
|
|
2953
|
+
for (const t of candidates) {
|
|
2954
|
+
if (!t.id && !t.getAttribute('x-colorpicker')) {
|
|
2955
|
+
registerDefaultColorpickerTemplate(t);
|
|
2956
|
+
break;
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
let root = null;
|
|
2961
|
+
if (_defaultColorpickerTemplate) {
|
|
2962
|
+
const frag = _defaultColorpickerTemplate.content.cloneNode(true);
|
|
2963
|
+
root = frag.firstElementChild;
|
|
2964
|
+
}
|
|
2965
|
+
if (root) {
|
|
2966
|
+
if (!root.hasAttribute('x-colorpicker')) root.setAttribute('x-colorpicker', panelsExpr || '');
|
|
2967
|
+
else if (panelsExpr) root.setAttribute('x-colorpicker', panelsExpr);
|
|
2968
|
+
if (root.tagName !== 'DIALOG' && !root.hasAttribute('popover')) root.setAttribute('popover', '');
|
|
2969
|
+
root.id = id;
|
|
2970
|
+
document.body.appendChild(root);
|
|
2971
|
+
if (window.Alpine?.initTree) Alpine.initTree(root);
|
|
2972
|
+
return root;
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
// No default template → empty <menu> populated by _injectDefaultUI on mount.
|
|
2976
|
+
const menu = document.createElement('menu');
|
|
2977
|
+
menu.setAttribute('popover', '');
|
|
2978
|
+
// Pass the panel-list expression through to the root x-colorpicker directive
|
|
2979
|
+
// so the auto-created popover only shows the panels the swatch requested.
|
|
2980
|
+
menu.setAttribute('x-colorpicker', panelsExpr || '');
|
|
2981
|
+
menu.id = id;
|
|
2982
|
+
menu.className = 'colorpicker dropdown-menu';
|
|
2983
|
+
document.body.appendChild(menu);
|
|
2984
|
+
if (window.Alpine?.initTree) Alpine.initTree(menu);
|
|
2985
|
+
return menu;
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
// Parse a directive expression as a panel list. Accepts JS array literals like
|
|
2989
|
+
// "['solid', 'gradient']" or "[\"library\"]". Returns a normalized array of
|
|
2990
|
+
// recognized panel names, or null if the expression isn't a panel list.
|
|
2991
|
+
const _validPanels = ['solid', 'gradient', 'library'];
|
|
2992
|
+
function parsePanelsExpression(expr) {
|
|
2993
|
+
if (!expr || typeof expr !== 'string') return null;
|
|
2994
|
+
const trimmed = expr.trim();
|
|
2995
|
+
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return null;
|
|
2996
|
+
try {
|
|
2997
|
+
// JSON.parse after normalizing single quotes — the expressions we accept
|
|
2998
|
+
// are simple string-array literals, so a quote swap is safe.
|
|
2999
|
+
const arr = JSON.parse(trimmed.replace(/'/g, '"'));
|
|
3000
|
+
if (!Array.isArray(arr)) return null;
|
|
3001
|
+
const out = arr
|
|
3002
|
+
.map(s => typeof s === 'string' ? s.trim().toLowerCase() : null)
|
|
3003
|
+
.filter(s => s && _validPanels.includes(s));
|
|
3004
|
+
return out.length ? out : null;
|
|
3005
|
+
} catch {
|
|
3006
|
+
return null;
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
registerPlugin();
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
// Track initialization
|
|
3014
|
+
let colorpickerPluginInitialized = false;
|
|
3015
|
+
function ensureColorpickerPluginInitialized() {
|
|
3016
|
+
if (colorpickerPluginInitialized) return;
|
|
3017
|
+
if (!window.Alpine || typeof window.Alpine.directive !== 'function') return;
|
|
3018
|
+
colorpickerPluginInitialized = true;
|
|
3019
|
+
initializeColorpickerPlugin();
|
|
3020
|
+
// Process any x-colorpicker elements already in the DOM
|
|
3021
|
+
if (window.Alpine && typeof window.Alpine.initTree === 'function') {
|
|
3022
|
+
document.querySelectorAll('[x-colorpicker]').forEach(el => { if (!el.__x) window.Alpine.initTree(el); });
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
window.ensureColorpickerPluginInitialized = ensureColorpickerPluginInitialized;
|
|
3026
|
+
|
|
3027
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ensureColorpickerPluginInitialized);
|
|
3028
|
+
document.addEventListener('alpine:init', ensureColorpickerPluginInitialized);
|
|
3029
|
+
if (window.Alpine && typeof window.Alpine.directive === 'function') setTimeout(ensureColorpickerPluginInitialized, 0);
|
|
3030
|
+
else {
|
|
3031
|
+
const check = setInterval(() => { if (window.Alpine && typeof window.Alpine.directive === 'function') { clearInterval(check); ensureColorpickerPluginInitialized(); } }, 10);
|
|
3032
|
+
setTimeout(() => clearInterval(check), 5000);
|
|
3033
|
+
}
|