@rogieking/figui3 11.6.1 → 11.6.2
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/.cursor/skills/fig-editor/components.md +10 -5
- package/.cursor/skills/fig-editor/reference.md +10 -4
- package/.cursor/skills/figui3/reference.md +8 -0
- package/CHANGELOG.md +21 -0
- package/README.md +28 -7
- package/dist/fig-color.js +1 -0
- package/dist/fig-editor.css +1 -1
- package/dist/fig-editor.js +3 -3
- package/dist/fig-property.css +1 -1
- package/dist/fig.js +5 -5
- package/fig-color.js +406 -0
- package/fig-editor.css +1 -16
- package/fig-editor.js +39 -594
- package/fig-gradient-editor.css +6 -1
- package/fig-gradient-editor.js +566 -237
- package/fig-palette-editor.css +20 -0
- package/fig-palette-editor.js +745 -0
- package/fig-popup-gradient.js +9 -1
- package/fig-popup-palette.js +13 -2
- package/fig-property.css +4 -0
- package/fig.js +16 -165
- package/package.json +7 -2
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
/* Copyright (c) 2026 Rogie King. PolyForm Shield 1.0.0. See LICENSE. */
|
|
2
|
+
import "./fig.js";
|
|
3
|
+
import "./fig-property.js";
|
|
4
|
+
import {
|
|
5
|
+
figHexToOklch,
|
|
6
|
+
figMapOklchToGamut,
|
|
7
|
+
figOklabDistance,
|
|
8
|
+
figOklchToHex,
|
|
9
|
+
figOklchToOklab,
|
|
10
|
+
} from "./fig-color.js";
|
|
11
|
+
|
|
12
|
+
function createElement(tagName, attributes = {}, children = []) {
|
|
13
|
+
const element = document.createElement(tagName);
|
|
14
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
15
|
+
if (value === null || value === undefined || value === false) continue;
|
|
16
|
+
if (name === "className") element.className = String(value);
|
|
17
|
+
else if (value === true) element.setAttribute(name, "");
|
|
18
|
+
else element.setAttribute(name, String(value));
|
|
19
|
+
}
|
|
20
|
+
const list = Array.isArray(children) ? children : [children];
|
|
21
|
+
for (const child of list) {
|
|
22
|
+
if (child === null || child === undefined || child === false) continue;
|
|
23
|
+
element.append(child instanceof Node ? child : String(child));
|
|
24
|
+
}
|
|
25
|
+
return element;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function booleanAttribute(element, name) {
|
|
29
|
+
return element.hasAttribute(name) && element.getAttribute(name) !== "false";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function normalizePaletteEditorValue(value) {
|
|
33
|
+
let entries = value;
|
|
34
|
+
if (typeof entries === "string") {
|
|
35
|
+
const raw = entries.trim();
|
|
36
|
+
if (!raw) return [];
|
|
37
|
+
try {
|
|
38
|
+
entries = JSON.parse(raw);
|
|
39
|
+
} catch {
|
|
40
|
+
entries = raw.includes(",") ? raw.split(",") : [raw];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(entries)) return [];
|
|
44
|
+
return entries.map((entry) => {
|
|
45
|
+
if (typeof entry === "string") {
|
|
46
|
+
const color = entry.trim();
|
|
47
|
+
return {
|
|
48
|
+
color: color.slice(0, 7) || "#D9D9D9",
|
|
49
|
+
alpha:
|
|
50
|
+
color.length > 7
|
|
51
|
+
? Math.max(0, Math.min(1, parseInt(color.slice(7, 9), 16) / 255))
|
|
52
|
+
: 1,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (entry && typeof entry === "object") {
|
|
56
|
+
const alpha =
|
|
57
|
+
entry.alpha !== undefined
|
|
58
|
+
? Number(entry.alpha)
|
|
59
|
+
: entry.opacity !== undefined
|
|
60
|
+
? Number(entry.opacity) / 100
|
|
61
|
+
: 1;
|
|
62
|
+
return {
|
|
63
|
+
color: String(entry.color || "#D9D9D9").slice(0, 7),
|
|
64
|
+
alpha: Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { color: "#D9D9D9", alpha: 1 };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function paletteEntryValue(entry) {
|
|
72
|
+
const color = String(entry?.color || "#D9D9D9").slice(0, 7);
|
|
73
|
+
const alpha = Math.max(0, Math.min(1, Number(entry?.alpha ?? 1)));
|
|
74
|
+
if (alpha >= 1) return color;
|
|
75
|
+
return `${color}${Math.round(alpha * 255)
|
|
76
|
+
.toString(16)
|
|
77
|
+
.padStart(2, "0")}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function median(values) {
|
|
81
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
82
|
+
const middle = Math.floor(sorted.length / 2);
|
|
83
|
+
return sorted.length % 2
|
|
84
|
+
? sorted[middle]
|
|
85
|
+
: (sorted[middle - 1] + sorted[middle]) / 2;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hueDistance(first, second) {
|
|
89
|
+
const difference = Math.abs(first - second) % 360;
|
|
90
|
+
return Math.min(difference, 360 - difference);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function paletteColorData(entry) {
|
|
94
|
+
let lch;
|
|
95
|
+
try {
|
|
96
|
+
lch = figHexToOklch(entry?.color || "#D9D9D9");
|
|
97
|
+
} catch {
|
|
98
|
+
lch = figHexToOklch("#D9D9D9");
|
|
99
|
+
}
|
|
100
|
+
return { lch, lab: figOklchToOklab(lch.l, lch.c, lch.h) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function paletteCandidate(l, c, h) {
|
|
104
|
+
const lch = figMapOklchToGamut(l, c, h);
|
|
105
|
+
return {
|
|
106
|
+
lch,
|
|
107
|
+
lab: figOklchToOklab(lch.l, lch.c, lch.h),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function minimumPaletteDistance(candidate, colors) {
|
|
112
|
+
return Math.min(
|
|
113
|
+
...colors.map((color) => figOklabDistance(candidate.lab, color.lab)),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function dominantPaletteHue(colors) {
|
|
118
|
+
let x = 0;
|
|
119
|
+
let y = 0;
|
|
120
|
+
for (const { lch } of colors) {
|
|
121
|
+
const weight = Math.max(lch.c, 0.001);
|
|
122
|
+
const radians = (lch.h * Math.PI) / 180;
|
|
123
|
+
x += Math.cos(radians) * weight;
|
|
124
|
+
y += Math.sin(radians) * weight;
|
|
125
|
+
}
|
|
126
|
+
return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function neutralPaletteCandidate(colors) {
|
|
130
|
+
const lightnesses = colors
|
|
131
|
+
.map(({ lch }) => lch.l)
|
|
132
|
+
.filter((lightness) => lightness > 0.08 && lightness < 0.92)
|
|
133
|
+
.sort((a, b) => a - b);
|
|
134
|
+
const points = [0.08, ...lightnesses, 0.92];
|
|
135
|
+
let widest = { start: points[0], end: points[1] };
|
|
136
|
+
for (let index = 1; index < points.length - 1; index += 1) {
|
|
137
|
+
const gap = points[index + 1] - points[index];
|
|
138
|
+
if (gap > widest.end - widest.start) {
|
|
139
|
+
widest = { start: points[index], end: points[index + 1] };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return paletteCandidate(
|
|
143
|
+
(widest.start + widest.end) / 2,
|
|
144
|
+
median(colors.map(({ lch }) => lch.c)),
|
|
145
|
+
dominantPaletteHue(colors),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sequentialPaletteCandidate(colors) {
|
|
150
|
+
if (colors.length < 2) return null;
|
|
151
|
+
const hue = dominantPaletteHue(colors);
|
|
152
|
+
if (colors.some(({ lch }) => lch.c >= 0.025 && hueDistance(lch.h, hue) > 25)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const deltas = colors.slice(1).map(
|
|
156
|
+
({ lch }, index) => lch.l - colors[index].lch.l,
|
|
157
|
+
);
|
|
158
|
+
const direction = Math.sign(colors.at(-1).lch.l - colors[0].lch.l);
|
|
159
|
+
if (
|
|
160
|
+
direction === 0 ||
|
|
161
|
+
deltas.some((delta) => Math.abs(delta) > 0.01 && Math.sign(delta) !== direction)
|
|
162
|
+
) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const step = Math.max(
|
|
166
|
+
0.06,
|
|
167
|
+
Math.min(0.18, median(deltas.map((delta) => Math.abs(delta)))),
|
|
168
|
+
);
|
|
169
|
+
const first = colors[0].lch;
|
|
170
|
+
const last = colors.at(-1).lch;
|
|
171
|
+
let lightness = last.l + direction * step;
|
|
172
|
+
let chroma = last.c;
|
|
173
|
+
if (lightness < 0.08 || lightness > 0.92) {
|
|
174
|
+
lightness = first.l - direction * step;
|
|
175
|
+
chroma = first.c;
|
|
176
|
+
}
|
|
177
|
+
return paletteCandidate(
|
|
178
|
+
Math.max(0.08, Math.min(0.92, lightness)),
|
|
179
|
+
chroma,
|
|
180
|
+
hue,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function categoricalPaletteCandidate(colors) {
|
|
185
|
+
const medianLightness = median(colors.map(({ lch }) => lch.l));
|
|
186
|
+
const chromatic = colors.filter(({ lch }) => lch.c >= 0.025);
|
|
187
|
+
const medianChroma = Math.max(
|
|
188
|
+
0.06,
|
|
189
|
+
Math.min(
|
|
190
|
+
0.22,
|
|
191
|
+
median((chromatic.length ? chromatic : colors).map(({ lch }) => lch.c)),
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
const lightnesses = [
|
|
195
|
+
medianLightness,
|
|
196
|
+
Math.max(0.2, medianLightness - 0.06),
|
|
197
|
+
Math.min(0.88, medianLightness + 0.06),
|
|
198
|
+
];
|
|
199
|
+
let best = null;
|
|
200
|
+
for (const lightness of lightnesses) {
|
|
201
|
+
for (let hue = 0; hue < 360; hue += 2) {
|
|
202
|
+
const candidate = paletteCandidate(lightness, medianChroma, hue);
|
|
203
|
+
const distance = minimumPaletteDistance(candidate, colors);
|
|
204
|
+
const score =
|
|
205
|
+
distance -
|
|
206
|
+
Math.abs(candidate.lch.l - medianLightness) * 0.12 -
|
|
207
|
+
Math.abs(candidate.lch.c - medianChroma) * 0.08;
|
|
208
|
+
if (!best || score > best.score) best = { ...candidate, score };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return best;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function generatePaletteEntry(entries) {
|
|
215
|
+
if (!entries.length) return { color: "#D9D9D9", alpha: 1 };
|
|
216
|
+
const colors = entries.map(paletteColorData);
|
|
217
|
+
const alpha = median(entries.map((entry) => Number(entry.alpha ?? 1)));
|
|
218
|
+
let candidate;
|
|
219
|
+
if (colors.every(({ lch }) => lch.c < 0.025)) {
|
|
220
|
+
candidate = neutralPaletteCandidate(colors);
|
|
221
|
+
} else {
|
|
222
|
+
candidate = sequentialPaletteCandidate(colors);
|
|
223
|
+
if (
|
|
224
|
+
!candidate ||
|
|
225
|
+
minimumPaletteDistance(candidate, colors) < 0.035
|
|
226
|
+
) {
|
|
227
|
+
candidate = categoricalPaletteCandidate(colors);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
color: figOklchToHex(
|
|
232
|
+
candidate.lch.l,
|
|
233
|
+
candidate.lch.c,
|
|
234
|
+
candidate.lch.h,
|
|
235
|
+
),
|
|
236
|
+
alpha,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function paletteEntriesEqual(first, second) {
|
|
241
|
+
const alphaByte = (value) =>
|
|
242
|
+
Math.round(Math.max(0, Math.min(1, Number(value ?? 1))) * 255);
|
|
243
|
+
return (
|
|
244
|
+
first.length === second.length &&
|
|
245
|
+
first.every(
|
|
246
|
+
(entry, index) =>
|
|
247
|
+
String(entry.color).toUpperCase() ===
|
|
248
|
+
String(second[index]?.color).toUpperCase() &&
|
|
249
|
+
alphaByte(entry.alpha) === alphaByte(second[index]?.alpha),
|
|
250
|
+
)
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function clonePalette(entries) {
|
|
255
|
+
return entries.map((entry) => ({ ...entry }));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function matchUnchangedPaletteEntries(previous, next) {
|
|
259
|
+
const rows = previous.length;
|
|
260
|
+
const columns = next.length;
|
|
261
|
+
const lengths = Array.from({ length: rows + 1 }, () =>
|
|
262
|
+
Array(columns + 1).fill(0),
|
|
263
|
+
);
|
|
264
|
+
const equal = (a, b) => paletteEntriesEqual([a], [b]);
|
|
265
|
+
for (let i = rows - 1; i >= 0; i -= 1) {
|
|
266
|
+
for (let j = columns - 1; j >= 0; j -= 1) {
|
|
267
|
+
lengths[i][j] = equal(previous[i], next[j])
|
|
268
|
+
? lengths[i + 1][j + 1] + 1
|
|
269
|
+
: Math.max(lengths[i + 1][j], lengths[i][j + 1]);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const matches = [];
|
|
273
|
+
let i = 0;
|
|
274
|
+
let j = 0;
|
|
275
|
+
while (i < rows && j < columns) {
|
|
276
|
+
if (equal(previous[i], next[j])) {
|
|
277
|
+
matches.push([i, j]);
|
|
278
|
+
i += 1;
|
|
279
|
+
j += 1;
|
|
280
|
+
} else if (lengths[i + 1][j] >= lengths[i][j + 1]) {
|
|
281
|
+
i += 1;
|
|
282
|
+
} else {
|
|
283
|
+
j += 1;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return matches;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A reusable editor for an ordered palette of solid colors.
|
|
291
|
+
* @attr {string} value - JSON array of hex strings or {color,alpha} objects, or comma-separated hex
|
|
292
|
+
* @attr {number} min - Minimum number of colors (default: 2)
|
|
293
|
+
* @attr {number} max - Maximum number of colors (unbounded when omitted)
|
|
294
|
+
* @attr {boolean} fixed - Locks palette length while preserving color editing and reordering
|
|
295
|
+
* @attr {boolean} disabled - Disables editing and reordering
|
|
296
|
+
* @attr {"fade"|"visible"} overflow - Vertical overflow mode (default: fade)
|
|
297
|
+
* @attr {number} show-rows - Visible rows before overflow (default: 6.5)
|
|
298
|
+
* @fires input - During color editing or reordering (detail: normalized color array)
|
|
299
|
+
* @fires change - On committed color edits, add, remove, or reorder
|
|
300
|
+
*/
|
|
301
|
+
class FigPaletteEditor extends HTMLElement {
|
|
302
|
+
#colors = [];
|
|
303
|
+
#syncingValueAttribute = false;
|
|
304
|
+
#mounted = false;
|
|
305
|
+
#reorder = null;
|
|
306
|
+
#addField = null;
|
|
307
|
+
#addButton = null;
|
|
308
|
+
#rows = [];
|
|
309
|
+
#renderedColors = [];
|
|
310
|
+
#pointerInteractionActive = false;
|
|
311
|
+
#deferredExternalColors = null;
|
|
312
|
+
|
|
313
|
+
static get observedAttributes() {
|
|
314
|
+
return [
|
|
315
|
+
"value",
|
|
316
|
+
"min",
|
|
317
|
+
"max",
|
|
318
|
+
"fixed",
|
|
319
|
+
"disabled",
|
|
320
|
+
"overflow",
|
|
321
|
+
"show-rows",
|
|
322
|
+
];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
get #min() {
|
|
326
|
+
const value = Number.parseInt(this.getAttribute("min"), 10);
|
|
327
|
+
return Number.isFinite(value) ? Math.max(0, value) : 2;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
get #max() {
|
|
331
|
+
const value = Number.parseInt(this.getAttribute("max"), 10);
|
|
332
|
+
return Number.isFinite(value) ? Math.max(this.#min, value) : Infinity;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
get #fixed() {
|
|
336
|
+
return booleanAttribute(this, "fixed");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
get #disabled() {
|
|
340
|
+
return booleanAttribute(this, "disabled");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
get #overflowMode() {
|
|
344
|
+
return this.getAttribute("overflow") === "visible" ? "visible" : "fade";
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
get #showRows() {
|
|
348
|
+
const value = Number(this.getAttribute("show-rows"));
|
|
349
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
get value() {
|
|
353
|
+
return this.#colors.map((entry) => ({ ...entry }));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
set value(value) {
|
|
357
|
+
this.setAttribute(
|
|
358
|
+
"value",
|
|
359
|
+
typeof value === "string" ? value : JSON.stringify(value ?? []),
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
connectedCallback() {
|
|
364
|
+
const incoming = normalizePaletteEditorValue(this.getAttribute("value"));
|
|
365
|
+
if (!paletteEntriesEqual(this.#colors, incoming)) {
|
|
366
|
+
this.#colors = incoming;
|
|
367
|
+
}
|
|
368
|
+
this.#mount();
|
|
369
|
+
this.#sync();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
373
|
+
if (oldValue === newValue || this.#syncingValueAttribute) return;
|
|
374
|
+
if (name === "value") {
|
|
375
|
+
const incoming = normalizePaletteEditorValue(newValue);
|
|
376
|
+
if (paletteEntriesEqual(this.#colors, incoming)) {
|
|
377
|
+
if (this.#pointerInteractionActive) {
|
|
378
|
+
this.#deferredExternalColors = null;
|
|
379
|
+
}
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (this.#pointerInteractionActive) {
|
|
383
|
+
this.#deferredExternalColors = incoming;
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
this.#deferredExternalColors = null;
|
|
387
|
+
this.#colors = incoming;
|
|
388
|
+
}
|
|
389
|
+
if (this.isConnected && this.#mounted) this.#sync();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
disconnectedCallback() {
|
|
393
|
+
this.#endPointerInteraction({ reconcile: false });
|
|
394
|
+
if (this.#deferredExternalColors) {
|
|
395
|
+
this.#colors = this.#deferredExternalColors;
|
|
396
|
+
this.#deferredExternalColors = null;
|
|
397
|
+
this.#reflectValue();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#reflectValue() {
|
|
402
|
+
this.#syncingValueAttribute = true;
|
|
403
|
+
this.setAttribute("value", JSON.stringify(this.#colors));
|
|
404
|
+
this.#syncingValueAttribute = false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
#emit(type) {
|
|
408
|
+
this.dispatchEvent(
|
|
409
|
+
new CustomEvent(type, {
|
|
410
|
+
bubbles: true,
|
|
411
|
+
detail: this.value,
|
|
412
|
+
}),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
#applyInternalColors(value, eventTypes, { focusIndex = null } = {}) {
|
|
417
|
+
const next = normalizePaletteEditorValue(value);
|
|
418
|
+
const changed = !paletteEntriesEqual(this.#colors, next);
|
|
419
|
+
if (changed) {
|
|
420
|
+
this.#colors = next;
|
|
421
|
+
this.#sync();
|
|
422
|
+
}
|
|
423
|
+
this.#reflectValue();
|
|
424
|
+
for (const type of Array.isArray(eventTypes) ? eventTypes : [eventTypes]) {
|
|
425
|
+
if (type === "change" || changed) this.#emit(type);
|
|
426
|
+
}
|
|
427
|
+
if (focusIndex !== null) this.#focusColorValue(focusIndex);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
#removeRow(row) {
|
|
431
|
+
const currentIndex = this.#rows.indexOf(row);
|
|
432
|
+
if (
|
|
433
|
+
this.#disabled ||
|
|
434
|
+
this.#fixed ||
|
|
435
|
+
this.#colors.length <= this.#min ||
|
|
436
|
+
!Number.isInteger(currentIndex) ||
|
|
437
|
+
currentIndex < 0
|
|
438
|
+
) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (row.contains(document.activeElement)) this.#addButton.focus();
|
|
442
|
+
const colors = clonePalette(this.#colors);
|
|
443
|
+
colors.splice(currentIndex, 1);
|
|
444
|
+
this.#applyInternalColors(colors, ["input", "change"]);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
#createRow() {
|
|
448
|
+
const colorInput = createElement("fig-input-color", {
|
|
449
|
+
className: "fig-palette-editor-color",
|
|
450
|
+
slot: "input",
|
|
451
|
+
text: true,
|
|
452
|
+
alpha: true,
|
|
453
|
+
full: true,
|
|
454
|
+
});
|
|
455
|
+
const syncColor = (event, type) => {
|
|
456
|
+
event.stopPropagation();
|
|
457
|
+
const row = colorInput.closest(".fig-palette-editor-color-row");
|
|
458
|
+
const currentIndex = this.#rows.indexOf(row);
|
|
459
|
+
if (!Number.isInteger(currentIndex) || !this.#colors[currentIndex]) return;
|
|
460
|
+
const colors = clonePalette(this.#colors);
|
|
461
|
+
const currentAlpha = colors[currentIndex]?.alpha ?? 1;
|
|
462
|
+
const inputAlpha = colorInput.rgba?.a ?? currentAlpha;
|
|
463
|
+
const alpha =
|
|
464
|
+
Math.round(inputAlpha * 255) === Math.round(currentAlpha * 255)
|
|
465
|
+
? currentAlpha
|
|
466
|
+
: inputAlpha;
|
|
467
|
+
colors[currentIndex] = {
|
|
468
|
+
color:
|
|
469
|
+
colorInput.hexOpaque ||
|
|
470
|
+
colors[currentIndex]?.color ||
|
|
471
|
+
"#D9D9D9",
|
|
472
|
+
alpha,
|
|
473
|
+
};
|
|
474
|
+
this.#applyInternalColors(colors, type);
|
|
475
|
+
};
|
|
476
|
+
colorInput.addEventListener("input", (event) =>
|
|
477
|
+
syncColor(event, "input"),
|
|
478
|
+
);
|
|
479
|
+
colorInput.addEventListener("change", (event) =>
|
|
480
|
+
syncColor(event, "change"),
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
const row = createElement(
|
|
484
|
+
"fig-property",
|
|
485
|
+
{
|
|
486
|
+
className: "fig-palette-editor-color-row",
|
|
487
|
+
leading: "drag",
|
|
488
|
+
},
|
|
489
|
+
colorInput,
|
|
490
|
+
);
|
|
491
|
+
row.addEventListener("remove", (event) => {
|
|
492
|
+
event.stopPropagation();
|
|
493
|
+
this.#removeRow(row);
|
|
494
|
+
});
|
|
495
|
+
return row;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
#handleReorder = (event) => {
|
|
499
|
+
const oldIndex = Number(event.detail?.oldIndex);
|
|
500
|
+
const newIndex = Number(event.detail?.newIndex);
|
|
501
|
+
if (
|
|
502
|
+
!Number.isInteger(oldIndex) ||
|
|
503
|
+
!Number.isInteger(newIndex) ||
|
|
504
|
+
oldIndex === newIndex ||
|
|
505
|
+
oldIndex < 0 ||
|
|
506
|
+
newIndex < 0 ||
|
|
507
|
+
oldIndex >= this.#colors.length ||
|
|
508
|
+
newIndex >= this.#colors.length
|
|
509
|
+
) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const colors = clonePalette(this.#colors);
|
|
513
|
+
const [entry] = colors.splice(oldIndex, 1);
|
|
514
|
+
colors.splice(newIndex, 0, entry);
|
|
515
|
+
const [row] = this.#rows.splice(oldIndex, 1);
|
|
516
|
+
this.#rows.splice(newIndex, 0, row);
|
|
517
|
+
this.#applyInternalColors(colors, ["input", "change"]);
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
#focusColorValue(index) {
|
|
521
|
+
requestAnimationFrame(() => {
|
|
522
|
+
if (!this.isConnected) return;
|
|
523
|
+
const input = this.querySelector(
|
|
524
|
+
`.fig-palette-editor-color-row[data-index="${index}"] .fig-palette-editor-color fig-input-text input`,
|
|
525
|
+
);
|
|
526
|
+
input?.focus();
|
|
527
|
+
input?.select();
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
#mount() {
|
|
532
|
+
if (this.#mounted) return;
|
|
533
|
+
this.#reorder = createElement("fig-reorder", {
|
|
534
|
+
axis: "vertical",
|
|
535
|
+
items: ".fig-palette-editor-color-row",
|
|
536
|
+
"aria-label": "Palette colors",
|
|
537
|
+
});
|
|
538
|
+
this.#addButton = createElement(
|
|
539
|
+
"fig-button",
|
|
540
|
+
{
|
|
541
|
+
className: "fig-palette-editor-add",
|
|
542
|
+
variant: "secondary",
|
|
543
|
+
full: true,
|
|
544
|
+
},
|
|
545
|
+
"Add color",
|
|
546
|
+
);
|
|
547
|
+
this.#addField = createElement("fig-field", {}, this.#addButton);
|
|
548
|
+
this.replaceChildren(this.#reorder);
|
|
549
|
+
this.#reorder.addEventListener("reorder", this.#handleReorder);
|
|
550
|
+
this.#addButton.addEventListener("click", this.#handleAdd);
|
|
551
|
+
this.addEventListener("pointerdown", this.#handlePointerDown, true);
|
|
552
|
+
this.addEventListener(
|
|
553
|
+
"lostpointercapture",
|
|
554
|
+
this.#handlePointerEnd,
|
|
555
|
+
true,
|
|
556
|
+
);
|
|
557
|
+
this.#mounted = true;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
#handleAdd = () => {
|
|
561
|
+
if (this.#disabled || this.#colors.length >= this.#max) return;
|
|
562
|
+
const colors = clonePalette(this.#colors);
|
|
563
|
+
const addedIndex = colors.length;
|
|
564
|
+
colors.push(generatePaletteEntry(colors));
|
|
565
|
+
this.#applyInternalColors(colors, ["input", "change"], {
|
|
566
|
+
focusIndex: addedIndex,
|
|
567
|
+
});
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
#reconcileRows() {
|
|
571
|
+
const previousRows = this.#rows.filter((row) => row.isConnected);
|
|
572
|
+
let nextRows;
|
|
573
|
+
if (previousRows.length === this.#colors.length) {
|
|
574
|
+
nextRows = [...previousRows];
|
|
575
|
+
} else {
|
|
576
|
+
nextRows = Array(this.#colors.length).fill(null);
|
|
577
|
+
const matches = matchUnchangedPaletteEntries(
|
|
578
|
+
this.#renderedColors,
|
|
579
|
+
this.#colors,
|
|
580
|
+
);
|
|
581
|
+
const usedPrevious = new Set();
|
|
582
|
+
for (const [previousIndex, nextIndex] of matches) {
|
|
583
|
+
const row = previousRows[previousIndex];
|
|
584
|
+
if (!row) continue;
|
|
585
|
+
nextRows[nextIndex] = row;
|
|
586
|
+
usedPrevious.add(previousIndex);
|
|
587
|
+
}
|
|
588
|
+
const reusable = previousRows.filter(
|
|
589
|
+
(_, index) => !usedPrevious.has(index),
|
|
590
|
+
);
|
|
591
|
+
nextRows = nextRows.map(
|
|
592
|
+
(row) => row ?? reusable.shift() ?? this.#createRow(),
|
|
593
|
+
);
|
|
594
|
+
const retained = new Set(nextRows);
|
|
595
|
+
previousRows.forEach((row) => {
|
|
596
|
+
if (!retained.has(row)) row.remove();
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
let cursor = this.#reorder.firstElementChild;
|
|
600
|
+
for (const row of nextRows) {
|
|
601
|
+
if (row === cursor) cursor = cursor.nextElementSibling;
|
|
602
|
+
else this.#reorder.insertBefore(row, cursor);
|
|
603
|
+
}
|
|
604
|
+
this.#rows = nextRows;
|
|
605
|
+
this.#rows.forEach((row, index) =>
|
|
606
|
+
this.#syncRow(row, this.#colors[index], index),
|
|
607
|
+
);
|
|
608
|
+
this.#renderedColors = clonePalette(this.#colors);
|
|
609
|
+
queueMicrotask(() => this.#syncRemoveActions());
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
#syncRow(row, entry, index) {
|
|
613
|
+
row.dataset.index = String(index);
|
|
614
|
+
if (this.#fixed) {
|
|
615
|
+
const remove = row.querySelector(
|
|
616
|
+
':scope > [data-fig-property-action="remove"]',
|
|
617
|
+
);
|
|
618
|
+
if (remove?.contains(document.activeElement)) {
|
|
619
|
+
row.querySelector(".fig-palette-editor-color")?.focus();
|
|
620
|
+
}
|
|
621
|
+
row.removeAttribute("trailing");
|
|
622
|
+
row.removeAttribute("show-trailing");
|
|
623
|
+
} else {
|
|
624
|
+
row.setAttribute("trailing", "remove");
|
|
625
|
+
row.setAttribute("show-trailing", "");
|
|
626
|
+
}
|
|
627
|
+
const color = row.querySelector(".fig-palette-editor-color");
|
|
628
|
+
const value = paletteEntryValue(entry);
|
|
629
|
+
this.#setColorValue(color, value);
|
|
630
|
+
color?.setAttribute("aria-label", `Palette color ${index + 1}`);
|
|
631
|
+
color?.toggleAttribute("disabled", this.#disabled);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
#setColorValue(control, value) {
|
|
635
|
+
if (!control || control.getAttribute("value") === value) return;
|
|
636
|
+
const active = control.contains(document.activeElement)
|
|
637
|
+
? document.activeElement
|
|
638
|
+
: null;
|
|
639
|
+
const selection =
|
|
640
|
+
active &&
|
|
641
|
+
"selectionStart" in active &&
|
|
642
|
+
typeof active.selectionStart === "number"
|
|
643
|
+
? {
|
|
644
|
+
start: active.selectionStart,
|
|
645
|
+
end: active.selectionEnd,
|
|
646
|
+
direction: active.selectionDirection,
|
|
647
|
+
}
|
|
648
|
+
: null;
|
|
649
|
+
control.setAttribute("value", value);
|
|
650
|
+
if (selection && active === document.activeElement) {
|
|
651
|
+
try {
|
|
652
|
+
active.setSelectionRange(
|
|
653
|
+
selection.start,
|
|
654
|
+
selection.end,
|
|
655
|
+
selection.direction,
|
|
656
|
+
);
|
|
657
|
+
} catch {
|
|
658
|
+
// Selection APIs are not available for every input type.
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
#syncRemoveActions() {
|
|
664
|
+
if (!this.isConnected) return;
|
|
665
|
+
this.#rows.forEach((row, index) => {
|
|
666
|
+
const remove = row.querySelector(
|
|
667
|
+
':scope > [data-fig-property-action="remove"]',
|
|
668
|
+
);
|
|
669
|
+
if (!remove) return;
|
|
670
|
+
remove.classList.add("fig-palette-editor-remove");
|
|
671
|
+
remove.setAttribute("aria-label", `Remove color ${index + 1}`);
|
|
672
|
+
remove.toggleAttribute(
|
|
673
|
+
"disabled",
|
|
674
|
+
this.#disabled || this.#colors.length <= this.#min,
|
|
675
|
+
);
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
#sync() {
|
|
680
|
+
if (!this.#mounted) return;
|
|
681
|
+
const fade = this.#overflowMode === "fade";
|
|
682
|
+
this.#reorder.classList.toggle("fig-overflow-fade", fade);
|
|
683
|
+
const showRows = this.#showRows;
|
|
684
|
+
if (showRows === null) {
|
|
685
|
+
this.#reorder.style.removeProperty(
|
|
686
|
+
"--fig-palette-editor-overflow-show-rows",
|
|
687
|
+
);
|
|
688
|
+
} else {
|
|
689
|
+
this.#reorder.style.setProperty(
|
|
690
|
+
"--fig-palette-editor-overflow-show-rows",
|
|
691
|
+
String(showRows),
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
this.#reorder.toggleAttribute("disabled", this.#disabled);
|
|
695
|
+
this.#reconcileRows();
|
|
696
|
+
this.#addButton.toggleAttribute(
|
|
697
|
+
"disabled",
|
|
698
|
+
this.#disabled || this.#colors.length >= this.#max,
|
|
699
|
+
);
|
|
700
|
+
if (this.#fixed) {
|
|
701
|
+
if (this.#addField.contains(document.activeElement)) {
|
|
702
|
+
this.#rows[0]
|
|
703
|
+
?.querySelector(".fig-palette-editor-color")
|
|
704
|
+
?.focus();
|
|
705
|
+
}
|
|
706
|
+
this.#addField.remove();
|
|
707
|
+
} else if (!this.#addField.isConnected) {
|
|
708
|
+
this.append(this.#addField);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
#handlePointerDown = (event) => {
|
|
713
|
+
if (event.button !== 0 || this.#pointerInteractionActive) return;
|
|
714
|
+
this.#pointerInteractionActive = true;
|
|
715
|
+
window.addEventListener("pointerup", this.#handlePointerEnd, true);
|
|
716
|
+
window.addEventListener("pointercancel", this.#handlePointerEnd, true);
|
|
717
|
+
window.addEventListener("blur", this.#handlePointerEnd, true);
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
#handlePointerEnd = () => {
|
|
721
|
+
window.removeEventListener("pointerup", this.#handlePointerEnd, true);
|
|
722
|
+
window.removeEventListener("pointercancel", this.#handlePointerEnd, true);
|
|
723
|
+
window.removeEventListener("blur", this.#handlePointerEnd, true);
|
|
724
|
+
queueMicrotask(() =>
|
|
725
|
+
this.#endPointerInteraction({ reconcile: true }),
|
|
726
|
+
);
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
#endPointerInteraction({ reconcile }) {
|
|
730
|
+
window.removeEventListener("pointerup", this.#handlePointerEnd, true);
|
|
731
|
+
window.removeEventListener("pointercancel", this.#handlePointerEnd, true);
|
|
732
|
+
window.removeEventListener("blur", this.#handlePointerEnd, true);
|
|
733
|
+
const wasActive = this.#pointerInteractionActive;
|
|
734
|
+
this.#pointerInteractionActive = false;
|
|
735
|
+
if (!reconcile || !wasActive || !this.#deferredExternalColors) return;
|
|
736
|
+
this.#colors = this.#deferredExternalColors;
|
|
737
|
+
this.#deferredExternalColors = null;
|
|
738
|
+
this.#reflectValue();
|
|
739
|
+
this.#sync();
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (!customElements.get("fig-palette-editor")) {
|
|
744
|
+
customElements.define("fig-palette-editor", FigPaletteEditor);
|
|
745
|
+
}
|