gradient-lab 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.
- package/AGENTS.md +135 -0
- package/README.md +103 -0
- package/app.css +342 -0
- package/app.js +13 -0
- package/components/app-hero.js +32 -0
- package/components/app-shell.js +12 -0
- package/components/gradient-editor.js +224 -0
- package/components/gradient-element.js +8 -0
- package/components/gradient-lab-app.js +120 -0
- package/components/gradient-library.js +60 -0
- package/components/gradient-workbench.js +18 -0
- package/components/image-sampling-stage.js +316 -0
- package/components/library-code.js +24 -0
- package/components/library-controls.js +45 -0
- package/components/lux-card.js +31 -0
- package/components/sampled-gradient-list.js +63 -0
- package/components/sampler-toolbar.js +100 -0
- package/components/toast-zone.js +19 -0
- package/index.html +56 -0
- package/package.json +41 -0
- package/reactive-framework.js +371 -0
- package/server.js +108 -0
- package/utils.js +114 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
|
|
4
|
+
"name": "gradient-lab",
|
|
5
|
+
"description": "",
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
|
|
8
|
+
"main": "index.js",
|
|
9
|
+
|
|
10
|
+
"bin": {
|
|
11
|
+
"odor": "server.js"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "./server.js",
|
|
15
|
+
"save": "git add .; git commit -m 'Updated Release'; npm version patch; npm publish; git push --follow-tags;"
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
"keywords": [
|
|
19
|
+
"blog",
|
|
20
|
+
"static-site",
|
|
21
|
+
"xml",
|
|
22
|
+
"workflow",
|
|
23
|
+
"symbolic"
|
|
24
|
+
],
|
|
25
|
+
|
|
26
|
+
"author": "catpea (https://github.com/catpea)",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/catpea/odor.git"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/catpea/odor/issues"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://catpea.github.io/odor",
|
|
36
|
+
"funding": {
|
|
37
|
+
"type": "github",
|
|
38
|
+
"url": "https://github.com/sponsors/catpea"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
const hasValue = value => value !== null && value !== undefined;
|
|
2
|
+
|
|
3
|
+
function disposeOne(item) {
|
|
4
|
+
if (!item) return;
|
|
5
|
+
if (typeof item === "function") return item();
|
|
6
|
+
if (typeof item.dispose === "function") return item.dispose();
|
|
7
|
+
if (typeof item[Symbol.dispose] === "function") return item[Symbol.dispose]();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
class Scope {
|
|
11
|
+
#items = [];
|
|
12
|
+
#disposed = false;
|
|
13
|
+
|
|
14
|
+
get disposed() { return this.#disposed; }
|
|
15
|
+
|
|
16
|
+
collect(...items) {
|
|
17
|
+
const flat = items.flat(Infinity).filter(Boolean);
|
|
18
|
+
if (this.#disposed) {
|
|
19
|
+
for (let i = flat.length - 1; i >= 0; i--) disposeOne(flat[i]);
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
this.#items.push(...flat);
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
defer(fn) {
|
|
27
|
+
return this.collect(fn);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
child() {
|
|
31
|
+
const scope = new Scope();
|
|
32
|
+
this.collect(scope);
|
|
33
|
+
return scope;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
timeout(fn, ms) {
|
|
37
|
+
const id = setTimeout(fn, ms);
|
|
38
|
+
this.collect(() => clearTimeout(id));
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interval(fn, ms) {
|
|
43
|
+
const id = setInterval(fn, ms);
|
|
44
|
+
this.collect(() => clearInterval(id));
|
|
45
|
+
return id;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
frame(fn) {
|
|
49
|
+
const id = requestAnimationFrame(fn);
|
|
50
|
+
this.collect(() => cancelAnimationFrame(id));
|
|
51
|
+
return id;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
dispose() {
|
|
55
|
+
if (this.#disposed) return;
|
|
56
|
+
this.#disposed = true;
|
|
57
|
+
const items = this.#items.splice(0);
|
|
58
|
+
for (let i = items.length - 1; i >= 0; i--) disposeOne(items[i]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class Signal {
|
|
63
|
+
#value;
|
|
64
|
+
#subscribers = new Set();
|
|
65
|
+
#equals;
|
|
66
|
+
|
|
67
|
+
constructor(value, options = {}) {
|
|
68
|
+
this.#equals = options.equals ?? Object.is;
|
|
69
|
+
if (arguments.length > 0) this.#value = value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get value() { return this.#value; }
|
|
73
|
+
set value(next) { this.set(next); }
|
|
74
|
+
get hasValue() { return hasValue(this.#value); }
|
|
75
|
+
get size() { return this.#subscribers.size; }
|
|
76
|
+
|
|
77
|
+
set(next, options = {}) {
|
|
78
|
+
const equals = options.force ? () => false : this.#equals;
|
|
79
|
+
if (equals(next, this.#value)) return false;
|
|
80
|
+
const previous = this.#value;
|
|
81
|
+
this.#value = next;
|
|
82
|
+
this.notify(previous);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
update(fn, options = {}) {
|
|
87
|
+
return this.set(fn(this.#value), options);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
mutate(fn) {
|
|
91
|
+
fn(this.#value);
|
|
92
|
+
this.notify(this.#value);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
subscribe(fn, options = {}) {
|
|
97
|
+
this.#subscribers.add(fn);
|
|
98
|
+
if (options.immediate !== false && this.hasValue) fn(this.#value, undefined);
|
|
99
|
+
return () => this.#subscribers.delete(fn);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
once(fn) {
|
|
103
|
+
const unsubscribe = this.subscribe((value, previous) => {
|
|
104
|
+
unsubscribe();
|
|
105
|
+
fn(value, previous);
|
|
106
|
+
});
|
|
107
|
+
return unsubscribe;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
notify(previous = this.#value) {
|
|
111
|
+
if (!this.hasValue) return;
|
|
112
|
+
for (const fn of [...this.#subscribers]) fn(this.#value, previous);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
map(fn) {
|
|
116
|
+
const mapped = new Signal(this.hasValue ? fn(this.#value) : undefined);
|
|
117
|
+
this.subscribe(value => mapped.value = fn(value));
|
|
118
|
+
return mapped;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
class Concern extends Scope {
|
|
123
|
+
#signals = new Map();
|
|
124
|
+
|
|
125
|
+
signal(name, value, options) {
|
|
126
|
+
if (value instanceof Signal) {
|
|
127
|
+
this.#signals.set(name, value);
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let signal = this.#signals.get(name);
|
|
132
|
+
if (!signal) {
|
|
133
|
+
signal = arguments.length > 1 ? new Signal(value, options) : new Signal(undefined, options);
|
|
134
|
+
this.#signals.set(name, signal);
|
|
135
|
+
return signal;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (arguments.length > 1) signal.value = value;
|
|
139
|
+
return signal;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
hasSignal(name) {
|
|
143
|
+
return this.#signals.has(name);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
resolve(source) {
|
|
147
|
+
return source instanceof Signal ? source : this.signal(source);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
value(source) {
|
|
151
|
+
return this.resolve(source).value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
set(source, value) {
|
|
155
|
+
this.resolve(source).value = value;
|
|
156
|
+
return this;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
update(source, fn) {
|
|
160
|
+
this.resolve(source).update(fn);
|
|
161
|
+
return this;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
subscribe(source, fn, options) {
|
|
165
|
+
const unsubscribe = this.resolve(source).subscribe(fn, options);
|
|
166
|
+
this.collect(unsubscribe);
|
|
167
|
+
return unsubscribe;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
effect(sources, fn, options = {}) {
|
|
171
|
+
const input = (Array.isArray(sources) ? sources : [sources]).map(source => this.resolve(source));
|
|
172
|
+
let cleanup = null;
|
|
173
|
+
let wiring = true;
|
|
174
|
+
|
|
175
|
+
const run = () => {
|
|
176
|
+
if (wiring) return;
|
|
177
|
+
const values = input.map(signal => signal.value);
|
|
178
|
+
if (!options.allowPartial && !values.every(hasValue)) return;
|
|
179
|
+
disposeOne(cleanup);
|
|
180
|
+
cleanup = fn(...values);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
for (const signal of input) this.collect(signal.subscribe(run, { immediate: false }));
|
|
184
|
+
this.collect(() => disposeOne(cleanup));
|
|
185
|
+
wiring = false;
|
|
186
|
+
run();
|
|
187
|
+
return this;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
computed(name, sources, fn, options = {}) {
|
|
191
|
+
const signal = this.signal(name, undefined, options);
|
|
192
|
+
this.effect(sources, (...values) => { signal.value = fn(...values); }, options);
|
|
193
|
+
return signal;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
attribute(name, value) {
|
|
197
|
+
if (hasValue(value)) this.signal(name).value = value;
|
|
198
|
+
return this.signal(name);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
attributes(element, names = element.constructor.observedAttributes ?? []) {
|
|
202
|
+
for (const name of names) this.signal(name);
|
|
203
|
+
return this;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
hydrateAttributes(element, names = element.constructor.observedAttributes ?? []) {
|
|
207
|
+
for (const name of names) {
|
|
208
|
+
if (element.hasAttribute(name)) this.attribute(name, element.getAttribute(name));
|
|
209
|
+
}
|
|
210
|
+
return this;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
reflectAttribute(source, element, attributeName = source) {
|
|
214
|
+
this.subscribe(source, value => {
|
|
215
|
+
if (value === false || value === null || value === undefined) element.removeAttribute(attributeName);
|
|
216
|
+
else element.setAttribute(attributeName, value === true ? "" : String(value));
|
|
217
|
+
});
|
|
218
|
+
return this;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
on(target, eventName, handler, options) {
|
|
222
|
+
target.addEventListener(eventName, handler, options);
|
|
223
|
+
this.collect(() => target.removeEventListener(eventName, handler, options));
|
|
224
|
+
return this;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
delegate(root, eventName, selector, handler, options) {
|
|
228
|
+
return this.on(root, eventName, event => {
|
|
229
|
+
const match = event.target.closest?.(selector);
|
|
230
|
+
if (match && root.contains(match)) handler(event, match);
|
|
231
|
+
}, options);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
bindText(source, node, fallback = "") {
|
|
235
|
+
this.subscribe(source, value => {
|
|
236
|
+
const next = hasValue(value) ? String(value) : fallback;
|
|
237
|
+
if (node.textContent !== next) node.textContent = next;
|
|
238
|
+
});
|
|
239
|
+
return this;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
bindHTML(source, node, fallback = "") {
|
|
243
|
+
this.subscribe(source, value => {
|
|
244
|
+
const next = hasValue(value) ? String(value) : fallback;
|
|
245
|
+
if (node.innerHTML !== next) node.innerHTML = next;
|
|
246
|
+
});
|
|
247
|
+
return this;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
bindValue(source, element, eventName = "input") {
|
|
251
|
+
const signal = this.resolve(source);
|
|
252
|
+
this.subscribe(signal, value => {
|
|
253
|
+
const next = hasValue(value) ? String(value) : "";
|
|
254
|
+
if (element.value !== next) element.value = next;
|
|
255
|
+
});
|
|
256
|
+
this.on(element, eventName, () => { signal.value = element.value; });
|
|
257
|
+
return this;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
bindChecked(source, element) {
|
|
261
|
+
const signal = this.resolve(source);
|
|
262
|
+
this.subscribe(signal, value => { element.checked = Boolean(value); });
|
|
263
|
+
this.on(element, "change", () => { signal.value = element.checked; });
|
|
264
|
+
return this;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
bindClass(source, element, fn) {
|
|
268
|
+
this.subscribe(source, value => {
|
|
269
|
+
const next = typeof fn === "function" ? fn(value) : value;
|
|
270
|
+
element.className = next || "";
|
|
271
|
+
});
|
|
272
|
+
return this;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
bindStyle(source, element, property, fn = value => value) {
|
|
276
|
+
this.subscribe(source, value => {
|
|
277
|
+
const next = fn(value);
|
|
278
|
+
if (next === null || next === undefined || next === false) element.style.removeProperty(property);
|
|
279
|
+
else element.style.setProperty(property, String(next));
|
|
280
|
+
});
|
|
281
|
+
return this;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
bindAttribute(source, element, attributeName, fn = value => value) {
|
|
285
|
+
this.subscribe(source, value => {
|
|
286
|
+
const next = fn(value);
|
|
287
|
+
if (next === false || next === null || next === undefined) element.removeAttribute(attributeName);
|
|
288
|
+
else element.setAttribute(attributeName, next === true ? "" : String(next));
|
|
289
|
+
});
|
|
290
|
+
return this;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
render(source, host, fn) {
|
|
294
|
+
this.subscribe(source, value => {
|
|
295
|
+
const result = fn(value);
|
|
296
|
+
if (typeof result === "string") host.innerHTML = result;
|
|
297
|
+
else if (result instanceof Node) host.replaceChildren(result);
|
|
298
|
+
else if (Array.isArray(result)) host.replaceChildren(...result);
|
|
299
|
+
});
|
|
300
|
+
return this;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
class ReactiveHTMLElement extends HTMLElement {
|
|
305
|
+
#mounted = false;
|
|
306
|
+
|
|
307
|
+
constructor() {
|
|
308
|
+
super();
|
|
309
|
+
this.concern = new Concern();
|
|
310
|
+
this.concern.attributes(this);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
connectedCallback() {
|
|
314
|
+
if (this.#mounted) return;
|
|
315
|
+
this.#mounted = true;
|
|
316
|
+
this.concern.hydrateAttributes(this);
|
|
317
|
+
this.mount?.();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
disconnectedCallback() {
|
|
321
|
+
this.unmount?.();
|
|
322
|
+
this.concern.dispose();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
326
|
+
if (Object.is(oldValue, newValue)) return;
|
|
327
|
+
this.concern.attribute(name, newValue);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
signal(name, value, options) { return this.concern.signal(name, value, options); }
|
|
331
|
+
subscribe(source, fn, options) { return this.concern.subscribe(source, fn, options); }
|
|
332
|
+
effect(sources, fn, options) { return this.concern.effect(sources, fn, options); }
|
|
333
|
+
computed(name, sources, fn, options) { return this.concern.computed(name, sources, fn, options); }
|
|
334
|
+
setSignal(name, value) { this.concern.set(name, value); return this; }
|
|
335
|
+
updateSignal(name, fn) { this.concern.update(name, fn); return this; }
|
|
336
|
+
|
|
337
|
+
$(selector) { return this.querySelector(selector); }
|
|
338
|
+
$$(selector) { return [...this.querySelectorAll(selector)]; }
|
|
339
|
+
|
|
340
|
+
html(content) {
|
|
341
|
+
this.innerHTML = content;
|
|
342
|
+
return this;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
appendTemplate(content) {
|
|
346
|
+
const template = document.createElement("template");
|
|
347
|
+
template.innerHTML = content.trim();
|
|
348
|
+
this.append(template.content.cloneNode(true));
|
|
349
|
+
return this;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
on(target, eventName, handler, options) {
|
|
353
|
+
if (typeof target === "string") return this.concern.on(this, target, eventName, handler);
|
|
354
|
+
return this.concern.on(target, eventName, handler, options);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
delegate(eventName, selector, handler, options) {
|
|
358
|
+
return this.concern.delegate(this, eventName, selector, handler, options);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
emit(type, detail = {}, options = {}) {
|
|
362
|
+
return this.dispatchEvent(new CustomEvent(type, {
|
|
363
|
+
detail,
|
|
364
|
+
bubbles: options.bubbles ?? true,
|
|
365
|
+
composed: options.composed ?? true,
|
|
366
|
+
cancelable: options.cancelable ?? false,
|
|
367
|
+
}));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export { Scope, Signal, Concern, ReactiveHTMLElement };
|
package/server.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
const START_PORT = 48187;
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
|
|
14
|
+
const ROOT = __dirname;
|
|
15
|
+
const PUBLIC = path.join(ROOT, "public");
|
|
16
|
+
|
|
17
|
+
const mime = {
|
|
18
|
+
".html": "text/html; charset=utf-8",
|
|
19
|
+
".css": "text/css; charset=utf-8",
|
|
20
|
+
".js": "text/javascript; charset=utf-8",
|
|
21
|
+
".json": "application/json; charset=utf-8",
|
|
22
|
+
".svg": "image/svg+xml",
|
|
23
|
+
".png": "image/png",
|
|
24
|
+
".jpg": "image/jpeg",
|
|
25
|
+
".jpeg": "image/jpeg",
|
|
26
|
+
".gif": "image/gif",
|
|
27
|
+
".webp": "image/webp",
|
|
28
|
+
".ico": "image/x-icon",
|
|
29
|
+
".txt": "text/plain; charset=utf-8",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function openBrowser(url) {
|
|
33
|
+
const platform = process.platform;
|
|
34
|
+
|
|
35
|
+
if (platform === "darwin") {
|
|
36
|
+
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
37
|
+
} else if (platform === "win32") {
|
|
38
|
+
spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
39
|
+
} else {
|
|
40
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function safeJoin(base, requestPath) {
|
|
45
|
+
const decoded = decodeURIComponent(requestPath);
|
|
46
|
+
const resolved = path.resolve(base, "." + decoded);
|
|
47
|
+
|
|
48
|
+
if (!resolved.startsWith(base)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return resolved;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function serveFile(res, filePath) {
|
|
56
|
+
try {
|
|
57
|
+
const data = await fs.readFile(filePath);
|
|
58
|
+
const type = mime[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
59
|
+
|
|
60
|
+
res.writeHead(200, { "Content-Type": type });
|
|
61
|
+
res.end(data);
|
|
62
|
+
} catch {
|
|
63
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
64
|
+
res.end("404 Not Found");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createServer() {
|
|
69
|
+
return http.createServer(async (req, res) => {
|
|
70
|
+
const url = new URL(req.url, "http://localhost");
|
|
71
|
+
|
|
72
|
+
if (url.pathname === "/") {
|
|
73
|
+
return serveFile(res, path.join(ROOT, "index.html"));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rootFile = safeJoin(ROOT, url.pathname);
|
|
77
|
+
|
|
78
|
+
if (!rootFile) {
|
|
79
|
+
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
80
|
+
return res.end("403 Forbidden");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return serveFile(res, rootFile);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function listenOnOpenPort(port) {
|
|
88
|
+
const server = createServer();
|
|
89
|
+
|
|
90
|
+
server.once("error", err => {
|
|
91
|
+
if (err.code === "EADDRINUSE") {
|
|
92
|
+
listenOnOpenPort(port + 1);
|
|
93
|
+
} else {
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
server.listen(port, () => {
|
|
99
|
+
const url = `http://localhost:${port}/`;
|
|
100
|
+
console.log(`Serving ${url}`);
|
|
101
|
+
console.log(`Root: ${ROOT}/index.html`);
|
|
102
|
+
console.log(`Public: ${PUBLIC}/`);
|
|
103
|
+
|
|
104
|
+
openBrowser(url);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
listenOnOpenPort(START_PORT);
|
package/utils.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export const clamp = (value, min = 0, max = 1) => Math.min(max, Math.max(min, value));
|
|
2
|
+
|
|
3
|
+
export const normalizeAngle = value => {
|
|
4
|
+
const number = Number(value);
|
|
5
|
+
if (!Number.isFinite(number)) return 90;
|
|
6
|
+
return Math.round(((number % 360) + 360) % 360);
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const directionToAngle = direction => {
|
|
10
|
+
const value = String(direction ?? "90deg").trim();
|
|
11
|
+
if (value.toLowerCase().endsWith("deg")) return normalizeAngle(value.slice(0, -3));
|
|
12
|
+
return ({ "to top": 0, "to right": 90, "to bottom": 180, "to left": 270 })[value] ?? 90;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const uid = prefix => `${prefix}-${Math.random().toString(36).slice(2, 8)}-${Date.now().toString(36)}`;
|
|
16
|
+
|
|
17
|
+
export const round = (number, places = 0) => Number(number.toFixed(places));
|
|
18
|
+
|
|
19
|
+
export const hex = value => value.toString(16).padStart(2, "0");
|
|
20
|
+
|
|
21
|
+
export const rgbToHex = ({ r, g, b }) => `#${hex(r)}${hex(g)}${hex(b)}`;
|
|
22
|
+
|
|
23
|
+
export const hexToRgb = color => {
|
|
24
|
+
const clean = color.replace("#", "").trim();
|
|
25
|
+
const full = clean.length === 3 ? clean.split("").map(ch => ch + ch).join("") : clean;
|
|
26
|
+
return {
|
|
27
|
+
r: parseInt(full.slice(0, 2), 16),
|
|
28
|
+
g: parseInt(full.slice(2, 4), 16),
|
|
29
|
+
b: parseInt(full.slice(4, 6), 16),
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function escapeHtml(value) {
|
|
34
|
+
return String(value)
|
|
35
|
+
.replaceAll("&", "&")
|
|
36
|
+
.replaceAll("<", "<")
|
|
37
|
+
.replaceAll(">", ">")
|
|
38
|
+
.replaceAll('"', """)
|
|
39
|
+
.replaceAll("'", "'");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function copyLine(line) {
|
|
43
|
+
return {
|
|
44
|
+
...line,
|
|
45
|
+
start: { ...line.start },
|
|
46
|
+
end: { ...line.end },
|
|
47
|
+
stops: line.stops.map(stop => ({ ...stop })),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function copyLibraryItem(item) {
|
|
52
|
+
return {
|
|
53
|
+
...item,
|
|
54
|
+
stops: item.stops.map(stop => ({ ...stop })),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function freshStops(count) {
|
|
59
|
+
const total = clamp(Number(count) || 5, 2, 16);
|
|
60
|
+
return Array.from({ length: total }, (_, index) => ({
|
|
61
|
+
id: uid("stop"),
|
|
62
|
+
pos: total === 1 ? 0 : round((index / (total - 1)) * 100, 1),
|
|
63
|
+
color: "#000000",
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function sortStops(stops) {
|
|
68
|
+
return stops.sort((a, b) => a.pos - b.pos);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function gradientCss(stops, direction = "90deg") {
|
|
72
|
+
const safeStops = sortStops(stops.map(stop => ({ ...stop })));
|
|
73
|
+
const body = safeStops.map(stop => `${stop.color} ${round(stop.pos, 1)}%`).join(", ");
|
|
74
|
+
return `linear-gradient(${direction}, ${body})`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function cssBlock(line, domain = "gradientlab.local") {
|
|
78
|
+
const stops = sortStops(line.stops.map(stop => ({ ...stop })));
|
|
79
|
+
const permalinkStops = stops.map(stop => `${stop.color.replace("#", "")}+${round(stop.pos, 1)}`).join(",");
|
|
80
|
+
return `/* Permalink - use to edit and share this gradient: https://${domain}/gradient-editor/#${permalinkStops} */
|
|
81
|
+
background: ${gradientCss(stops, line.direction)};`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function libraryCss(library, prefix = "gr-") {
|
|
85
|
+
return library.map((item, index) => {
|
|
86
|
+
const className = `.${prefix}${index + 1}`.replace("..", ".");
|
|
87
|
+
return `${className} {
|
|
88
|
+
background: ${gradientCss(item.stops, item.direction)};
|
|
89
|
+
}`;
|
|
90
|
+
}).join(`\n\n`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function interpolateColor(stops, position) {
|
|
94
|
+
const sorted = sortStops(stops.map(stop => ({ ...stop })));
|
|
95
|
+
const pos = clamp(position / 100, 0, 1) * 100;
|
|
96
|
+
let left = sorted[0];
|
|
97
|
+
let right = sorted[sorted.length - 1];
|
|
98
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
99
|
+
if (pos >= sorted[i].pos && pos <= sorted[i + 1].pos) {
|
|
100
|
+
left = sorted[i];
|
|
101
|
+
right = sorted[i + 1];
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (left.id === right.id || left.pos === right.pos) return left.color;
|
|
106
|
+
const t = clamp((pos - left.pos) / (right.pos - left.pos), 0, 1);
|
|
107
|
+
const a = hexToRgb(left.color);
|
|
108
|
+
const b = hexToRgb(right.color);
|
|
109
|
+
return rgbToHex({
|
|
110
|
+
r: Math.round(a.r + (b.r - a.r) * t),
|
|
111
|
+
g: Math.round(a.g + (b.g - a.g) * t),
|
|
112
|
+
b: Math.round(a.b + (b.b - a.b) * t),
|
|
113
|
+
});
|
|
114
|
+
}
|