@womp/mcp-ui 0.1.12 → 0.1.14
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/dist/index.js +8 -8
- package/dist/openai-camera/index.html +145 -19
- package/package.json +1 -1
- package/src/openai-camera/index.html +128 -2
- package/src/openai-camera/main.ts +161 -17
package/package.json
CHANGED
|
@@ -120,6 +120,102 @@
|
|
|
120
120
|
outline-offset: 2px;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/* Any colour: the native picker (Chromium's has an eyedropper of its own). */
|
|
124
|
+
.pick {
|
|
125
|
+
position: relative;
|
|
126
|
+
flex: none;
|
|
127
|
+
width: 22px;
|
|
128
|
+
height: 22px;
|
|
129
|
+
border-radius: 50%;
|
|
130
|
+
border: 1px solid rgba(0, 0, 0, 0.18);
|
|
131
|
+
background: conic-gradient(#ff5e5e, #ffd93d, #6bdc6b, #4fc3f7, #7b6cf6, #f06bd8, #ff5e5e);
|
|
132
|
+
cursor: pointer;
|
|
133
|
+
box-sizing: border-box;
|
|
134
|
+
}
|
|
135
|
+
.pick input {
|
|
136
|
+
position: absolute;
|
|
137
|
+
inset: 0;
|
|
138
|
+
width: 100%;
|
|
139
|
+
height: 100%;
|
|
140
|
+
opacity: 0;
|
|
141
|
+
padding: 0;
|
|
142
|
+
border: 0;
|
|
143
|
+
cursor: pointer;
|
|
144
|
+
}
|
|
145
|
+
.icon-button {
|
|
146
|
+
flex: none;
|
|
147
|
+
display: inline-grid;
|
|
148
|
+
place-items: center;
|
|
149
|
+
width: 24px;
|
|
150
|
+
height: 24px;
|
|
151
|
+
padding: 0;
|
|
152
|
+
border-radius: 50%;
|
|
153
|
+
border: 1px solid var(--color-border-primary, rgba(0, 0, 0, 0.14));
|
|
154
|
+
background: transparent;
|
|
155
|
+
color: var(--color-text-primary, #1b1b1a);
|
|
156
|
+
font: inherit;
|
|
157
|
+
font-size: 14px;
|
|
158
|
+
line-height: 1;
|
|
159
|
+
cursor: pointer;
|
|
160
|
+
}
|
|
161
|
+
.icon-button[hidden] { display: none; }
|
|
162
|
+
.icon-button:disabled { opacity: 0.4; cursor: default; }
|
|
163
|
+
|
|
164
|
+
.mode {
|
|
165
|
+
flex: none;
|
|
166
|
+
display: inline-flex;
|
|
167
|
+
gap: 2px;
|
|
168
|
+
padding: 2px;
|
|
169
|
+
border-radius: var(--border-radius-full, 999px);
|
|
170
|
+
border: 1px solid var(--color-border-primary, rgba(0, 0, 0, 0.14));
|
|
171
|
+
}
|
|
172
|
+
.mode button {
|
|
173
|
+
font: inherit;
|
|
174
|
+
font-size: var(--font-text-xs-size, 12px);
|
|
175
|
+
padding: 3px 10px;
|
|
176
|
+
border: 0;
|
|
177
|
+
border-radius: var(--border-radius-full, 999px);
|
|
178
|
+
background: transparent;
|
|
179
|
+
color: var(--color-text-secondary, rgba(27, 27, 26, 0.62));
|
|
180
|
+
cursor: pointer;
|
|
181
|
+
}
|
|
182
|
+
.mode button[aria-pressed="true"] {
|
|
183
|
+
background: var(--color-text-primary, #1b1b1a);
|
|
184
|
+
color: var(--color-background-primary, #ffffff);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* A gradient: its colours on a track, the one being edited ringed. */
|
|
188
|
+
.stops {
|
|
189
|
+
display: flex;
|
|
190
|
+
align-items: center;
|
|
191
|
+
gap: 6px;
|
|
192
|
+
min-width: 0;
|
|
193
|
+
}
|
|
194
|
+
.stops[hidden] { display: none; }
|
|
195
|
+
.track {
|
|
196
|
+
position: relative;
|
|
197
|
+
width: 132px;
|
|
198
|
+
height: 22px;
|
|
199
|
+
border-radius: var(--border-radius-full, 999px);
|
|
200
|
+
border: 1px solid rgba(0, 0, 0, 0.18);
|
|
201
|
+
box-sizing: border-box;
|
|
202
|
+
}
|
|
203
|
+
.stop {
|
|
204
|
+
position: absolute;
|
|
205
|
+
top: 50%;
|
|
206
|
+
width: 18px;
|
|
207
|
+
height: 18px;
|
|
208
|
+
margin: -9px 0 0 -9px;
|
|
209
|
+
padding: 0;
|
|
210
|
+
border-radius: 50%;
|
|
211
|
+
border: 2px solid #ffffff;
|
|
212
|
+
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
|
213
|
+
cursor: pointer;
|
|
214
|
+
}
|
|
215
|
+
.stop[aria-pressed="true"] {
|
|
216
|
+
box-shadow: 0 0 0 2px var(--color-text-primary, #1b1b1a);
|
|
217
|
+
}
|
|
218
|
+
|
|
123
219
|
.foot {
|
|
124
220
|
display: flex;
|
|
125
221
|
align-items: center;
|
|
@@ -212,8 +308,38 @@
|
|
|
212
308
|
|
|
213
309
|
<div class="view" id="view-design">
|
|
214
310
|
<div class="controls">
|
|
215
|
-
<div class="row"
|
|
216
|
-
|
|
311
|
+
<div class="row">
|
|
312
|
+
<span>Shell</span>
|
|
313
|
+
<div class="mode" role="group" aria-label="Shell finish">
|
|
314
|
+
<button type="button" id="solid-shell" aria-pressed="true">Solid</button>
|
|
315
|
+
<button type="button" id="gradient-shell" aria-pressed="false">Gradient</button>
|
|
316
|
+
</div>
|
|
317
|
+
<div class="stops" id="stops-shell" hidden></div>
|
|
318
|
+
</div>
|
|
319
|
+
<div class="row">
|
|
320
|
+
<span></span>
|
|
321
|
+
<div class="swatches" id="sw-shell"></div>
|
|
322
|
+
<label class="pick" title="Any colour"><input type="color" id="pick-shell" aria-label="Shell: any colour" /></label>
|
|
323
|
+
<button class="icon-button" type="button" id="dropper-shell" title="Pick a colour from the screen" aria-label="Shell: pick a colour from the screen" hidden>
|
|
324
|
+
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m2 22 1-1h3l9-9"/><path d="M3 21v-3l9-9"/><path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.4.4a2.1 2.1 0 1 1-3 3l-3.8-3.8a2.1 2.1 0 1 1 3-3l.4.4Z"/></svg>
|
|
325
|
+
</button>
|
|
326
|
+
</div>
|
|
327
|
+
<div class="row">
|
|
328
|
+
<span>Button</span>
|
|
329
|
+
<div class="mode" role="group" aria-label="Button finish">
|
|
330
|
+
<button type="button" id="solid-button" aria-pressed="true">Solid</button>
|
|
331
|
+
<button type="button" id="gradient-button" aria-pressed="false">Gradient</button>
|
|
332
|
+
</div>
|
|
333
|
+
<div class="stops" id="stops-button" hidden></div>
|
|
334
|
+
</div>
|
|
335
|
+
<div class="row">
|
|
336
|
+
<span></span>
|
|
337
|
+
<div class="swatches" id="sw-button"></div>
|
|
338
|
+
<label class="pick" title="Any colour"><input type="color" id="pick-button" aria-label="Button: any colour" /></label>
|
|
339
|
+
<button class="icon-button" type="button" id="dropper-button" title="Pick a colour from the screen" aria-label="Button: pick a colour from the screen" hidden>
|
|
340
|
+
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m2 22 1-1h3l9-9"/><path d="M3 21v-3l9-9"/><path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.4.4a2.1 2.1 0 1 1-3 3l-3.8-3.8a2.1 2.1 0 1 1 3-3l.4.4Z"/></svg>
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
217
343
|
</div>
|
|
218
344
|
<div class="foot">
|
|
219
345
|
<div class="price" id="price"></div>
|
|
@@ -17,7 +17,9 @@ import { $, callTool, createCard, pushModelContext, toggleFullscreen } from "../
|
|
|
17
17
|
* for any reason other than its own write.
|
|
18
18
|
*
|
|
19
19
|
* Colours are not viewer knobs (they live in the scene's materials), so a swatch
|
|
20
|
-
* saves and then reloads the viewer with the new revision.
|
|
20
|
+
* saves and then reloads the viewer with the new revision. Each part is solid
|
|
21
|
+
* or a gradient of 2-3 colours; the colours come from the palette, the native
|
|
22
|
+
* colour picker, or the eyedropper where the browser has one.
|
|
21
23
|
*
|
|
22
24
|
* Ordering happens in the card: Order (or a `checkout_camera` result, which
|
|
23
25
|
* arrives with mode "checkout") opens the shipping form, and the form submits to
|
|
@@ -39,7 +41,8 @@ interface Design {
|
|
|
39
41
|
viewerOrigin: string;
|
|
40
42
|
sceneUrl: string;
|
|
41
43
|
options: DesignOption[];
|
|
42
|
-
|
|
44
|
+
/** Each part's colours: one is solid, more is a gradient. */
|
|
45
|
+
colors: Record<Slot, string[]>;
|
|
43
46
|
palette: { name: string; hex: string }[];
|
|
44
47
|
price: { amount: number; currency: string; label: string };
|
|
45
48
|
lastChange?: { source: string; writer: string; fields: string[] };
|
|
@@ -55,6 +58,13 @@ type Slot = "shell" | "button";
|
|
|
55
58
|
const SLOTS: Slot[] = ["shell", "button"];
|
|
56
59
|
type View = "design" | "order" | "done";
|
|
57
60
|
|
|
61
|
+
/** The most colours a gradient takes (the server holds the same limit). */
|
|
62
|
+
const MAX_COLORS = 3;
|
|
63
|
+
|
|
64
|
+
/** Chromium's EyeDropper API; absent elsewhere. */
|
|
65
|
+
type EyeDropperCtor = new () => { open(): Promise<{ sRGBHex: string }> };
|
|
66
|
+
const EyeDropper = (window as Window & { EyeDropper?: EyeDropperCtor }).EyeDropper;
|
|
67
|
+
|
|
58
68
|
const CONTACT_FIELDS = ["name", "email", "address", "city", "postcode", "country"] as const;
|
|
59
69
|
|
|
60
70
|
/** Marks this card's own writes, so its polling does not reload the viewer for them. */
|
|
@@ -78,6 +88,8 @@ let readyTimer: number | undefined;
|
|
|
78
88
|
let pollTimer: number | undefined;
|
|
79
89
|
let lastActivity = Date.now();
|
|
80
90
|
let statusTimer: number | undefined;
|
|
91
|
+
/** Which colour of a part's gradient the swatches, picker and eyedropper set. */
|
|
92
|
+
const activeStop: Record<Slot, number> = { shell: 0, button: 0 };
|
|
81
93
|
|
|
82
94
|
function setStatus(text: string, clearAfterMs?: number) {
|
|
83
95
|
clearTimeout(statusTimer);
|
|
@@ -165,8 +177,8 @@ function showFallback() {
|
|
|
165
177
|
"The 3D preview needs WebGPU, which this browser or device doesn't offer. You can still change the design here or in chat, and order it.";
|
|
166
178
|
const list = document.createElement("dl");
|
|
167
179
|
const rows: [string, string][] = [
|
|
168
|
-
["Shell colour", design.colors.shell],
|
|
169
|
-
["Button colour", design.colors.button],
|
|
180
|
+
["Shell colour", paintText(design.colors.shell)],
|
|
181
|
+
["Button colour", paintText(design.colors.button)],
|
|
170
182
|
...design.options.map((o) => [o.label, o.display] as [string, string]),
|
|
171
183
|
];
|
|
172
184
|
for (const [k, v] of rows) {
|
|
@@ -192,8 +204,17 @@ function flatten(scripts: unknown): Record<string, number> {
|
|
|
192
204
|
return out;
|
|
193
205
|
}
|
|
194
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Where the viewer's messages come from. A host that frames the card on an
|
|
209
|
+
* opaque origin (a sandbox without allow-same-origin) passes that sandbox down
|
|
210
|
+
* to the viewer's frame, which then speaks as "null" — still our frame, as
|
|
211
|
+
* `event.source` proves.
|
|
212
|
+
*/
|
|
213
|
+
const fromViewer = (event: MessageEvent, d: Design) =>
|
|
214
|
+
event.source === viewer.contentWindow && (event.origin === d.viewerOrigin || event.origin === "null");
|
|
215
|
+
|
|
195
216
|
window.addEventListener("message", (event) => {
|
|
196
|
-
if (!design || event
|
|
217
|
+
if (!design || !fromViewer(event, design)) return;
|
|
197
218
|
const msg = event.data as { type?: string; scripts?: unknown };
|
|
198
219
|
if (msg?.type === "kakapo:ready") {
|
|
199
220
|
viewerReady = true;
|
|
@@ -213,7 +234,8 @@ window.addEventListener("message", (event) => {
|
|
|
213
234
|
// The viewer has no change event, so read its knobs on an interval while visible.
|
|
214
235
|
window.setInterval(() => {
|
|
215
236
|
if (!design || !viewerReady || document.visibilityState !== "visible") return;
|
|
216
|
-
|
|
237
|
+
// "*": under an opaque host the viewer's origin is "null", which no target origin can name. The request carries nothing.
|
|
238
|
+
viewer.contentWindow?.postMessage({ type: "kakapo:getParams" }, "*");
|
|
217
239
|
}, 1_500);
|
|
218
240
|
|
|
219
241
|
function onViewerParams(params: Record<string, number>) {
|
|
@@ -233,10 +255,10 @@ function onViewerParams(params: Record<string, number>) {
|
|
|
233
255
|
// ---- saving -------------------------------------------------------------------
|
|
234
256
|
|
|
235
257
|
/**
|
|
236
|
-
* Save the viewer's knobs (and a
|
|
237
|
-
* quick changes cannot race each other's baseRev.
|
|
258
|
+
* Save the viewer's knobs (and a part's colours, if they changed). Serialised,
|
|
259
|
+
* so two quick changes cannot race each other's baseRev.
|
|
238
260
|
*/
|
|
239
|
-
async function flushSync(colors?: Partial<Record<Slot, string>>) {
|
|
261
|
+
async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
|
|
240
262
|
clearTimeout(syncTimer);
|
|
241
263
|
syncTimer = undefined;
|
|
242
264
|
while (syncing) await syncing;
|
|
@@ -280,9 +302,14 @@ async function flushSync(colors?: Partial<Record<Slot, string>>) {
|
|
|
280
302
|
}
|
|
281
303
|
}
|
|
282
304
|
|
|
305
|
+
/** "coral", or "coral → violet gradient" */
|
|
306
|
+
function paintText(paint: string[]) {
|
|
307
|
+
return paint.length > 1 ? `${paint.join(" → ")} gradient` : paint[0];
|
|
308
|
+
}
|
|
309
|
+
|
|
283
310
|
const contextLine = (d: Design) =>
|
|
284
311
|
`The user adjusted their camera in the card. Design ${d.designId}, revision ${d.rev}: ` +
|
|
285
|
-
`shell ${d.colors.shell}, button ${d.colors.button}; ` +
|
|
312
|
+
`shell ${paintText(d.colors.shell)}, button ${paintText(d.colors.button)}; ` +
|
|
286
313
|
d.options.map((o) => `${o.label} ${o.display}`).join(", ") +
|
|
287
314
|
".";
|
|
288
315
|
|
|
@@ -321,15 +348,30 @@ async function poll() {
|
|
|
321
348
|
|
|
322
349
|
// ---- controls -----------------------------------------------------------------
|
|
323
350
|
|
|
351
|
+
/** A colour as CSS: a palette name's swatch, or the hex itself. */
|
|
352
|
+
function cssColor(color: string) {
|
|
353
|
+
return design?.palette.find((p) => p.name === color)?.hex ?? color;
|
|
354
|
+
}
|
|
355
|
+
|
|
324
356
|
function render() {
|
|
325
357
|
if (!design) return;
|
|
326
358
|
for (const slot of SLOTS) {
|
|
359
|
+
const paint = design.colors[slot];
|
|
360
|
+
const gradient = paint.length > 1;
|
|
361
|
+
activeStop[slot] = Math.min(activeStop[slot], paint.length - 1);
|
|
362
|
+
const chosen = paint[activeStop[slot]];
|
|
363
|
+
|
|
364
|
+
$(`solid-${slot}`).setAttribute("aria-pressed", String(!gradient));
|
|
365
|
+
$(`gradient-${slot}`).setAttribute("aria-pressed", String(gradient));
|
|
366
|
+
renderStops(slot, paint);
|
|
367
|
+
|
|
327
368
|
const row = $(`sw-${slot}`);
|
|
328
369
|
row.textContent = "";
|
|
329
|
-
const chosen = design.colors[slot];
|
|
330
370
|
const entries = [...design.palette];
|
|
331
|
-
// A
|
|
332
|
-
|
|
371
|
+
// A hex (from the model, the picker or the eyedropper) has no swatch of its own — show it first.
|
|
372
|
+
for (const color of [...new Set(paint)].reverse()) {
|
|
373
|
+
if (color.startsWith("#") && !entries.some((p) => p.name === color)) entries.unshift({ name: color, hex: color });
|
|
374
|
+
}
|
|
333
375
|
for (const p of entries) {
|
|
334
376
|
const b = document.createElement("button");
|
|
335
377
|
b.type = "button";
|
|
@@ -341,18 +383,120 @@ function render() {
|
|
|
341
383
|
b.addEventListener("click", () => void pickColor(slot, p.name));
|
|
342
384
|
row.append(b);
|
|
343
385
|
}
|
|
386
|
+
($(`pick-${slot}`) as HTMLInputElement).value = cssColor(chosen);
|
|
387
|
+
$(`dropper-${slot}`).hidden = !EyeDropper;
|
|
344
388
|
}
|
|
345
389
|
$("price").textContent = `Full-colour print · ${design.price.label}`;
|
|
346
390
|
$("order-summary").textContent =
|
|
347
|
-
`Your camera, printed in full colour: ${design.price.label}.
|
|
391
|
+
`Your camera, printed in full colour: ${design.price.label}. ` +
|
|
392
|
+
`Shell ${paintText(design.colors.shell)}, button ${paintText(design.colors.button)}.`;
|
|
348
393
|
if (!fallback.hidden) showFallback();
|
|
349
394
|
}
|
|
350
395
|
|
|
351
|
-
|
|
352
|
-
|
|
396
|
+
/** A gradient's colours on a track, first to last; picking one makes it the one the swatches set. */
|
|
397
|
+
function renderStops(slot: Slot, paint: string[]) {
|
|
398
|
+
const box = $(`stops-${slot}`);
|
|
399
|
+
box.hidden = paint.length < 2;
|
|
400
|
+
box.textContent = "";
|
|
401
|
+
if (paint.length < 2) return;
|
|
402
|
+
const track = document.createElement("div");
|
|
403
|
+
track.className = "track";
|
|
404
|
+
track.style.background = `linear-gradient(90deg, ${paint.map(cssColor).join(", ")})`;
|
|
405
|
+
paint.forEach((color, i) => {
|
|
406
|
+
const stop = document.createElement("button");
|
|
407
|
+
stop.type = "button";
|
|
408
|
+
stop.className = "stop";
|
|
409
|
+
stop.style.left = `calc(11px + (100% - 22px) * ${i / (paint.length - 1)})`;
|
|
410
|
+
stop.style.background = cssColor(color);
|
|
411
|
+
stop.title = color;
|
|
412
|
+
stop.setAttribute("aria-label", `${slot} gradient colour ${i + 1} of ${paint.length}: ${color}`);
|
|
413
|
+
stop.setAttribute("aria-pressed", String(i === activeStop[slot]));
|
|
414
|
+
stop.addEventListener("click", () => {
|
|
415
|
+
activeStop[slot] = i;
|
|
416
|
+
render();
|
|
417
|
+
});
|
|
418
|
+
track.append(stop);
|
|
419
|
+
});
|
|
420
|
+
const add = iconButton("+", `${slot}: add a gradient colour`, paint.length >= MAX_COLORS, () => {
|
|
421
|
+
activeStop[slot] = paint.length;
|
|
422
|
+
void setPaint(slot, [...paint, partner(paint[paint.length - 1])]);
|
|
423
|
+
});
|
|
424
|
+
const remove = iconButton("−", `${slot}: remove the selected gradient colour`, paint.length <= 2, () => {
|
|
425
|
+
void setPaint(slot, paint.filter((_, i) => i !== activeStop[slot]));
|
|
426
|
+
});
|
|
427
|
+
box.append(track, add, remove);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function iconButton(text: string, label: string, disabled: boolean, onClick: () => void) {
|
|
431
|
+
const b = document.createElement("button");
|
|
432
|
+
b.type = "button";
|
|
433
|
+
b.className = "icon-button";
|
|
434
|
+
b.textContent = text;
|
|
435
|
+
b.title = label;
|
|
436
|
+
b.setAttribute("aria-label", label);
|
|
437
|
+
b.disabled = disabled;
|
|
438
|
+
b.addEventListener("click", onClick);
|
|
439
|
+
return b;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** A colour that reads well next to `color`, for a gradient's new end. */
|
|
443
|
+
function partner(color: string) {
|
|
444
|
+
return color === "violet" ? "sky" : "violet";
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The eyedropper's answer as #rrggbb; some Chromium versions say `rgb(r, g, b)`. */
|
|
448
|
+
function toHex(color: string) {
|
|
449
|
+
const rgb = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(color);
|
|
450
|
+
if (!rgb) return color.toLowerCase();
|
|
451
|
+
return `#${rgb.slice(1, 4).map((c) => Number(c).toString(16).padStart(2, "0")).join("")}`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Set the colour being edited: the part's only colour, or the selected one of its gradient. */
|
|
455
|
+
async function pickColor(slot: Slot, color: string) {
|
|
456
|
+
if (!design) return;
|
|
457
|
+
const paint = [...design.colors[slot]];
|
|
458
|
+
paint[activeStop[slot]] = color;
|
|
459
|
+
await setPaint(slot, paint);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function setPaint(slot: Slot, paint: string[]) {
|
|
463
|
+
if (!design) return;
|
|
464
|
+
const current = design.colors[slot];
|
|
465
|
+
if (paint.length === current.length && paint.every((c, i) => c === current[i])) return;
|
|
353
466
|
lastActivity = Date.now();
|
|
467
|
+
// Show it at once; the save's answer replaces it.
|
|
468
|
+
design = { ...design, colors: { ...design.colors, [slot]: paint } };
|
|
469
|
+
render();
|
|
354
470
|
setStatus("Saving…");
|
|
355
|
-
await flushSync({ [slot]:
|
|
471
|
+
await flushSync({ [slot]: paint });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
for (const slot of SLOTS) {
|
|
475
|
+
$(`solid-${slot}`).addEventListener("click", () => {
|
|
476
|
+
if (!design || design.colors[slot].length < 2) return;
|
|
477
|
+
const keep = design.colors[slot][activeStop[slot]];
|
|
478
|
+
activeStop[slot] = 0;
|
|
479
|
+
void setPaint(slot, [keep]);
|
|
480
|
+
});
|
|
481
|
+
$(`gradient-${slot}`).addEventListener("click", () => {
|
|
482
|
+
if (!design || design.colors[slot].length > 1) return;
|
|
483
|
+
const [only] = design.colors[slot];
|
|
484
|
+
activeStop[slot] = 1;
|
|
485
|
+
void setPaint(slot, [only, partner(only)]);
|
|
486
|
+
});
|
|
487
|
+
// `change`, not `input`: the picker fires `input` on every drag, and each save reloads the viewer.
|
|
488
|
+
$(`pick-${slot}`).addEventListener("change", (event) => {
|
|
489
|
+
void pickColor(slot, (event.target as HTMLInputElement).value.toLowerCase());
|
|
490
|
+
});
|
|
491
|
+
$(`dropper-${slot}`).addEventListener("click", async () => {
|
|
492
|
+
if (!EyeDropper) return;
|
|
493
|
+
try {
|
|
494
|
+
const { sRGBHex } = await new EyeDropper().open();
|
|
495
|
+
await pickColor(slot, toHex(sRGBHex));
|
|
496
|
+
} catch {
|
|
497
|
+
// Escape cancels it; a host may also refuse it.
|
|
498
|
+
}
|
|
499
|
+
});
|
|
356
500
|
}
|
|
357
501
|
|
|
358
502
|
// ---- ordering -----------------------------------------------------------------
|