@zakkster/lite-gradient-studio 1.0.0

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.
@@ -0,0 +1,447 @@
1
+ /**
2
+ * CSS gradient string → editable Gradient Studio state.
3
+ *
4
+ * Counterpart to the export emitters. Accepts the CSS we produce, plus
5
+ * the broadly-used subset designers paste from other tools:
6
+ *
7
+ * linear-gradient([angle] [in oklch], <color> [pos], ...)
8
+ * radial-gradient([shape] [size] [at <pos>] [in oklch], <color> [pos], ...)
9
+ * conic-gradient([from <angle>] [at <pos>] [in oklch], <color> [pos], ...)
10
+ *
11
+ * Color formats: 3/4/6/8-char hex, rgb(), rgba(), oklch() with optional
12
+ * `/ alpha`, hsl(), hsla(). Named colors are not supported (the table
13
+ * is huge and 99% of pasted gradients use hex or rgb).
14
+ *
15
+ * Returns a normalized state object matching the 1D editor's schema —
16
+ * the app's loadG1d picks it up directly. Throws on malformed input
17
+ * (caller shows a friendly error).
18
+ *
19
+ * Not goals (yet):
20
+ * - Multiple background-image layers separated by top-level comma
21
+ * (the outer parser still splits on commas at depth 0, but only
22
+ * the first gradient is parsed; future versions can layer)
23
+ * - Direction keywords ("to right", "to bottom left") — translated
24
+ * to numeric angles here in a small table; partial coverage
25
+ * - `<length>` positions (px, em). Only `<percentage>` accepted.
26
+ * - Color spaces other than oklch. The `in <space>` modifier is
27
+ * parsed but currently ignored — Studio always interpolates in OKLCH.
28
+ */
29
+
30
+ import { fromHex } from './color-convert.js';
31
+
32
+ /* ── public entry ─────────────────────────────────────────────── */
33
+
34
+ /**
35
+ * Parse a CSS gradient string. Returns a 1D-state-shaped object:
36
+ * { mode, stops, angle?, radShape?, radCx?, radCy?, conFrom?, conCx?, conCy? }
37
+ *
38
+ * The fields not relevant to the parsed mode are left undefined (e.g.
39
+ * radShape is undefined for linear gradients).
40
+ *
41
+ * @throws Error on malformed input — message is user-facing.
42
+ */
43
+ export function parseGradientCss(input) {
44
+ if (typeof input !== 'string') throw new Error('parseGradientCss: expected a string');
45
+ // Trim any leading `background:` / `background-image:` / trailing `;`
46
+ // — paste convenience.
47
+ let src = input.trim();
48
+ src = src.replace(/^background(?:-image)?\s*:\s*/i, '');
49
+ src = src.replace(/;[\s\n]*$/, '');
50
+ src = src.trim();
51
+
52
+ // Detect mode + extract inside of outer parens.
53
+ const m = /^(linear|radial|conic)-gradient\s*\(([\s\S]+)\)\s*$/i.exec(src);
54
+ if (!m) throw new Error('parseGradientCss: not a CSS gradient function');
55
+ const mode = m[1].toLowerCase();
56
+ const body = m[2];
57
+
58
+ // Top-level comma split (respecting nested parens — color functions
59
+ // contain commas).
60
+ const parts = splitTopLevel(body);
61
+ if (parts.length < 2) {
62
+ throw new Error('parseGradientCss: need at least two color stops');
63
+ }
64
+
65
+ // First part: either a direction/shape preamble or already a color.
66
+ // We sniff by attempting to parse the first part as a color — if it
67
+ // succeeds, it's a color (any format we support); if it throws, it
68
+ // must be a preamble. This catches named colors that a regex-based
69
+ // sniff would miss (`"red"` doesn't start with `#`/`rgb`/`oklch`).
70
+ const firstTrim = parts[0].trim();
71
+ const { color: firstColorPart } = splitColorAndPos(firstTrim);
72
+ let looksLikeColor = false;
73
+ try { parseColor(firstColorPart); looksLikeColor = true; }
74
+ catch { /* not a color → preamble */ }
75
+
76
+ let preamble = '';
77
+ let stopParts;
78
+ if (looksLikeColor) {
79
+ // No preamble — implicit defaults for the mode.
80
+ stopParts = parts;
81
+ } else {
82
+ preamble = firstTrim;
83
+ stopParts = parts.slice(1);
84
+ }
85
+
86
+ if (stopParts.length < 2) {
87
+ throw new Error('parseGradientCss: need at least two color stops');
88
+ }
89
+
90
+ const stops = parseStops(stopParts, mode);
91
+
92
+ if (mode === 'linear') {
93
+ return { mode, stops, angle: parseLinearAngle(preamble) };
94
+ }
95
+ if (mode === 'radial') {
96
+ const { shape, cx, cy } = parseRadialPreamble(preamble);
97
+ return { mode, stops, radShape: shape, radCx: cx, radCy: cy };
98
+ }
99
+ // conic
100
+ const { from, cx, cy } = parseConicPreamble(preamble);
101
+ return { mode, stops, conFrom: from, conCx: cx, conCy: cy };
102
+ }
103
+
104
+ /* ── splitting utilities ───────────────────────────────────────── */
105
+
106
+ /**
107
+ * Split on commas that aren't inside parens. Color functions like
108
+ * `rgba(255, 0, 0, 0.5)` contain commas we don't want to split on.
109
+ */
110
+ function splitTopLevel(s) {
111
+ const parts = [];
112
+ let depth = 0;
113
+ let start = 0;
114
+ for (let i = 0; i < s.length; i++) {
115
+ const ch = s[i];
116
+ if (ch === '(') depth++;
117
+ else if (ch === ')') depth--;
118
+ else if (ch === ',' && depth === 0) {
119
+ parts.push(s.slice(start, i));
120
+ start = i + 1;
121
+ }
122
+ }
123
+ parts.push(s.slice(start));
124
+ return parts;
125
+ }
126
+
127
+ /* ── stop parsing ─────────────────────────────────────────────── */
128
+
129
+ function parseStops(parts, mode) {
130
+ const stops = [];
131
+ const positionUnit = mode === 'conic' ? 'deg' : '%';
132
+ // For conic, positions are angles 0..360deg. For linear/radial they
133
+ // are percentages 0..100%. Both normalize to 0..1 in our stop schema.
134
+
135
+ for (const part of parts) {
136
+ const { color, position } = splitColorAndPos(part.trim());
137
+ const oklch = parseColor(color);
138
+ stops.push({
139
+ l: oklch.l, c: oklch.c, h: oklch.h,
140
+ a: oklch.a === undefined ? 1 : oklch.a,
141
+ // Position deferred — may be missing on some stops; fill in
142
+ // a second pass below.
143
+ stop: position === null
144
+ ? null
145
+ : normalizeStopPos(position, positionUnit, mode),
146
+ });
147
+ }
148
+
149
+ // Fill in missing positions: evenly distribute among unset ones,
150
+ // bracketed by their neighbours. Common pattern: paste
151
+ // "linear-gradient(red, blue)" → no positions at all.
152
+ fillMissingPositions(stops);
153
+ return stops;
154
+ }
155
+
156
+ /**
157
+ * Split a stop-spec into color + optional position. The color may
158
+ * itself contain spaces (e.g. `oklch(0.5 0.2 90)`), so we look for the
159
+ * LAST whitespace-separated token and check if it's a position.
160
+ */
161
+ function splitColorAndPos(s) {
162
+ // Strip trailing whitespace.
163
+ s = s.trim();
164
+ // Search backwards for a position token: <number><unit>
165
+ const m = /(.+?)\s+(-?\d+(?:\.\d+)?(?:%|deg|turn|rad))\s*$/.exec(s);
166
+ if (m) return { color: m[1].trim(), position: m[2] };
167
+ return { color: s, position: null };
168
+ }
169
+
170
+ /** Convert a position token like '50%' / '0.25turn' / '90deg' to 0..1. */
171
+ function normalizeStopPos(pos, unit, mode) {
172
+ const m = /^(-?\d+(?:\.\d+)?)(%|deg|turn|rad)$/.exec(pos);
173
+ if (!m) throw new Error(`parseGradientCss: malformed position "${pos}"`);
174
+ const n = parseFloat(m[1]);
175
+ const u = m[2];
176
+ let normalized;
177
+ if (u === '%') normalized = n / 100;
178
+ else if (u === 'deg') normalized = n / 360;
179
+ else if (u === 'turn') normalized = n;
180
+ else normalized = n / (2 * Math.PI);
181
+ // For linear/radial we ignore deg units (they'd be unusual; bug if
182
+ // it happens). For conic, we want the deg/turn interpretation.
183
+ // Either way, clamp to [0, 1] so out-of-range positions don't break
184
+ // the rail.
185
+ return Math.max(0, Math.min(1, normalized));
186
+ }
187
+
188
+ function fillMissingPositions(stops) {
189
+ // Walk runs of null positions, distribute evenly between the last
190
+ // known position (or 0) and the next known position (or 1).
191
+ const n = stops.length;
192
+ if (stops[0].stop === null) stops[0].stop = 0;
193
+ if (stops[n - 1].stop === null) stops[n - 1].stop = 1;
194
+ let i = 0;
195
+ while (i < n) {
196
+ if (stops[i].stop !== null) { i++; continue; }
197
+ // Find run end.
198
+ let j = i;
199
+ while (j < n && stops[j].stop === null) j++;
200
+ // stops[i-1].stop is the anchor before, stops[j].stop the anchor after.
201
+ const before = stops[i - 1].stop;
202
+ const after = stops[j].stop;
203
+ const count = j - i + 1; // including anchors at j
204
+ for (let k = i; k < j; k++) {
205
+ stops[k].stop = before + (after - before) * ((k - i + 1) / count);
206
+ }
207
+ i = j;
208
+ }
209
+ }
210
+
211
+ /* ── color parsing ────────────────────────────────────────────── */
212
+
213
+ function parseColor(s) {
214
+ const t = s.trim();
215
+
216
+ // Hex
217
+ if (t.startsWith('#')) return fromHex(t);
218
+
219
+ // rgb()/rgba()
220
+ let m = /^rgba?\s*\(([^)]+)\)\s*$/i.exec(t);
221
+ if (m) return rgbFunctionToOklch(m[1]);
222
+
223
+ // oklch()
224
+ m = /^oklch\s*\(([^)]+)\)\s*$/i.exec(t);
225
+ if (m) return oklchFunctionToObj(m[1]);
226
+
227
+ // hsl()/hsla()
228
+ m = /^hsla?\s*\(([^)]+)\)\s*$/i.exec(t);
229
+ if (m) return hslFunctionToOklch(m[1]);
230
+
231
+ // Named color — small table covering the CSS Level 1 + a few
232
+ // extensions designers actually paste. Not exhaustive; if a user
233
+ // needs hotpink they'll paste hex.
234
+ const named = NAMED_COLORS[t.toLowerCase()];
235
+ if (named) return fromHex(named);
236
+
237
+ throw new Error(`parseGradientCss: unsupported color "${t}"`);
238
+ }
239
+
240
+ const NAMED_COLORS = {
241
+ black: '#000000',
242
+ silver: '#c0c0c0',
243
+ gray: '#808080',
244
+ grey: '#808080',
245
+ white: '#ffffff',
246
+ maroon: '#800000',
247
+ red: '#ff0000',
248
+ purple: '#800080',
249
+ fuchsia: '#ff00ff',
250
+ magenta: '#ff00ff',
251
+ // CSS "green" is dark green; designers usually mean bright. Map
252
+ // "green" to CSS-spec dark green and provide "lime" for bright.
253
+ green: '#008000',
254
+ lime: '#00ff00',
255
+ olive: '#808000',
256
+ yellow: '#ffff00',
257
+ navy: '#000080',
258
+ blue: '#0000ff',
259
+ teal: '#008080',
260
+ aqua: '#00ffff',
261
+ cyan: '#00ffff',
262
+ orange: '#ffa500',
263
+ pink: '#ffc0cb',
264
+ brown: '#a52a2a',
265
+ transparent: '#00000000',
266
+ };
267
+
268
+ function rgbFunctionToOklch(inner) {
269
+ // Accept comma OR space separators, optional alpha with `/` or `,`.
270
+ const tokens = inner.split(/[\s,\/]+/).filter(Boolean);
271
+ if (tokens.length < 3) throw new Error('parseGradientCss: malformed rgb()');
272
+ const r = parseColorComponent(tokens[0], 255);
273
+ const g = parseColorComponent(tokens[1], 255);
274
+ const b = parseColorComponent(tokens[2], 255);
275
+ const a = tokens[3] !== undefined
276
+ ? parseColorComponent(tokens[3], 1, true)
277
+ : 1;
278
+ // Build hex and reuse fromHex for the RGB→OKLCH path.
279
+ const hex = '#' + (
280
+ ((r * 255 + 0.5) | 0).toString(16).padStart(2, '0') +
281
+ ((g * 255 + 0.5) | 0).toString(16).padStart(2, '0') +
282
+ ((b * 255 + 0.5) | 0).toString(16).padStart(2, '0')
283
+ );
284
+ const oklch = fromHex(hex);
285
+ oklch.a = a;
286
+ return oklch;
287
+ }
288
+
289
+ function oklchFunctionToObj(inner) {
290
+ // oklch(L C H [/ A]) — L can be "50%" or "0.5", others are numbers.
291
+ // Split on whitespace; the optional `/` denotes alpha.
292
+ const slashIdx = inner.indexOf('/');
293
+ const main = slashIdx >= 0 ? inner.slice(0, slashIdx) : inner;
294
+ const alphaPart = slashIdx >= 0 ? inner.slice(slashIdx + 1) : null;
295
+ const tokens = main.trim().split(/\s+/);
296
+ if (tokens.length < 3) throw new Error('parseGradientCss: malformed oklch()');
297
+ const l = parseColorComponent(tokens[0], 1, true); // 0..1 or 0..100%
298
+ const c = parseFloat(tokens[1]); // chroma 0..0.5ish
299
+ const h = parseFloat(tokens[2]); // hue 0..360
300
+ const a = alphaPart !== null
301
+ ? parseColorComponent(alphaPart.trim(), 1, true)
302
+ : 1;
303
+ return { l, c, h, a };
304
+ }
305
+
306
+ function hslFunctionToOklch(inner) {
307
+ const tokens = inner.split(/[\s,\/]+/).filter(Boolean);
308
+ if (tokens.length < 3) throw new Error('parseGradientCss: malformed hsl()');
309
+ let h = parseFloat(tokens[0]);
310
+ // Strip 'deg'/'turn'/'rad' unit suffix on hue.
311
+ if (/turn/i.test(tokens[0])) h *= 360;
312
+ if (/rad/i.test(tokens[0])) h *= 180 / Math.PI;
313
+ const s = parseColorComponent(tokens[1], 1, true);
314
+ const lt = parseColorComponent(tokens[2], 1, true);
315
+ const a = tokens[3] !== undefined ? parseColorComponent(tokens[3], 1, true) : 1;
316
+ // HSL → RGB → hex → fromHex (same path rgb uses).
317
+ const [r, g, b] = hslToRgb(h, s, lt);
318
+ const hex = '#' + (
319
+ ((r * 255 + 0.5) | 0).toString(16).padStart(2, '0') +
320
+ ((g * 255 + 0.5) | 0).toString(16).padStart(2, '0') +
321
+ ((b * 255 + 0.5) | 0).toString(16).padStart(2, '0')
322
+ );
323
+ const oklch = fromHex(hex);
324
+ oklch.a = a;
325
+ return oklch;
326
+ }
327
+
328
+ function hslToRgb(h, s, l) {
329
+ h = ((h % 360) + 360) % 360;
330
+ const c = (1 - Math.abs(2 * l - 1)) * s;
331
+ const hp = h / 60;
332
+ const x = c * (1 - Math.abs((hp % 2) - 1));
333
+ let r1, g1, b1;
334
+ if (hp <= 1) { r1 = c; g1 = x; b1 = 0; }
335
+ else if (hp <= 2) { r1 = x; g1 = c; b1 = 0; }
336
+ else if (hp <= 3) { r1 = 0; g1 = c; b1 = x; }
337
+ else if (hp <= 4) { r1 = 0; g1 = x; b1 = c; }
338
+ else if (hp <= 5) { r1 = x; g1 = 0; b1 = c; }
339
+ else { r1 = c; g1 = 0; b1 = x; }
340
+ const mLight = l - c / 2;
341
+ return [r1 + mLight, g1 + mLight, b1 + mLight];
342
+ }
343
+
344
+ /**
345
+ * Parse a single component which may be "127", "50%", or "0.5".
346
+ * @param {string} tok
347
+ * @param {number} max Range max when the value is not a percentage.
348
+ * @param {boolean} [floatOK] If true, treat bare values as already-normalized 0..1.
349
+ */
350
+ function parseColorComponent(tok, max, floatOK) {
351
+ if (tok.endsWith('%')) return parseFloat(tok) / 100;
352
+ const v = parseFloat(tok);
353
+ if (floatOK && v <= 1) return v;
354
+ return v / max;
355
+ }
356
+
357
+ /* ── preamble parsers (per mode) ──────────────────────────────── */
358
+
359
+ const DIRECTION_KEYWORDS = {
360
+ 'to top': 0,
361
+ 'to top right': 45,
362
+ 'to right': 90,
363
+ 'to bottom right': 135,
364
+ 'to bottom': 180,
365
+ 'to bottom left': 225,
366
+ 'to left': 270,
367
+ 'to top left': 315,
368
+ };
369
+
370
+ function parseLinearAngle(preamble) {
371
+ if (!preamble) return 180; // CSS default for linear-gradient
372
+ // Drop the `in <space>` modifier if present.
373
+ const clean = preamble.replace(/\s+in\s+\S+/i, '').trim();
374
+ if (!clean) return 180;
375
+ // Direction keyword?
376
+ const kw = DIRECTION_KEYWORDS[clean.toLowerCase()];
377
+ if (kw !== undefined) return kw;
378
+ // <angle> — accept deg, turn, rad.
379
+ const m = /^(-?\d+(?:\.\d+)?)(deg|turn|rad)?\s*$/.exec(clean);
380
+ if (!m) return 180;
381
+ let n = parseFloat(m[1]);
382
+ if (m[2] === 'turn') n *= 360;
383
+ if (m[2] === 'rad') n *= 180 / Math.PI;
384
+ // Wrap into 0..360.
385
+ return ((n % 360) + 360) % 360;
386
+ }
387
+
388
+ function parseRadialPreamble(preamble) {
389
+ let shape = 'ellipse';
390
+ let cx = 50, cy = 50;
391
+ if (!preamble) return { shape, cx, cy };
392
+ // Drop `in <space>`.
393
+ const clean = preamble.replace(/\s+in\s+\S+/i, '').trim();
394
+ if (/\bcircle\b/i.test(clean)) shape = 'circle';
395
+ if (/\bellipse\b/i.test(clean)) shape = 'ellipse';
396
+ // Extract position from `at <x> <y>`.
397
+ const m = /\bat\s+([^,]+)$/i.exec(clean);
398
+ if (m) {
399
+ const pos = parsePosition(m[1].trim());
400
+ cx = pos.cx;
401
+ cy = pos.cy;
402
+ }
403
+ return { shape, cx, cy };
404
+ }
405
+
406
+ function parseConicPreamble(preamble) {
407
+ let from = 0;
408
+ let cx = 50, cy = 50;
409
+ if (!preamble) return { from, cx, cy };
410
+ const clean = preamble.replace(/\s+in\s+\S+/i, '').trim();
411
+ const fromMatch = /\bfrom\s+(-?\d+(?:\.\d+)?)(deg|turn|rad)?/i.exec(clean);
412
+ if (fromMatch) {
413
+ let n = parseFloat(fromMatch[1]);
414
+ if (fromMatch[2] === 'turn') n *= 360;
415
+ if (fromMatch[2] === 'rad') n *= 180 / Math.PI;
416
+ from = ((n % 360) + 360) % 360;
417
+ }
418
+ const atMatch = /\bat\s+(.+)$/i.exec(clean);
419
+ if (atMatch) {
420
+ const pos = parsePosition(atMatch[1].trim());
421
+ cx = pos.cx;
422
+ cy = pos.cy;
423
+ }
424
+ return { from, cx, cy };
425
+ }
426
+
427
+ /**
428
+ * `<position>` → { cx, cy } in 0..100 percent. Accepts keywords
429
+ * (center, top, etc.), single-axis keyword pairs, and `<percentage>
430
+ * <percentage>` numeric pairs.
431
+ */
432
+ function parsePosition(s) {
433
+ const t = s.trim().toLowerCase();
434
+ // Numeric: "30% 70%" or "50% 50%"
435
+ const num = /^(-?\d+(?:\.\d+)?)\s*%\s+(-?\d+(?:\.\d+)?)\s*%$/.exec(t);
436
+ if (num) return { cx: parseFloat(num[1]), cy: parseFloat(num[2]) };
437
+ // Single percent: "30%" — y defaults to 50.
438
+ const single = /^(-?\d+(?:\.\d+)?)\s*%$/.exec(t);
439
+ if (single) return { cx: parseFloat(single[1]), cy: 50 };
440
+ // Keyword pair or single keyword.
441
+ let cx = 50, cy = 50;
442
+ if (t.includes('left')) cx = 0;
443
+ if (t.includes('right')) cx = 100;
444
+ if (t.includes('top')) cy = 0;
445
+ if (t.includes('bottom')) cy = 100;
446
+ return { cx, cy };
447
+ }