-
1
-
2
-
3
-
4
-
5
-
6
-
7
-
8
-
9
-
10
-
11
-
12
-
13
-
14
-
15
-
16
-
17
-
18
-
19
-
20
-
21
-
22
-
23
-
24
-
25
-
26
-
27
-
28
-
29
-
30
-
31
-
32
-
33
-
34
-
35
-
36
-
37
-
38
-
39
-
40
-
41
-
42
-
43
-
44
-
45
-
46
-
47
-
48
-
49
-
50
-
51
-
52
-
53
-
54
-
55
-
56
-
57
-
58
-
59
-
60
-
61
-
62
-
63
-
64
-
65
-
66
-
67
-
68
-
69
-
70
-
71
-
72
-
73
-
74
-
75
-
76
-
77
-
78
-
79
-
80
-
81
-
82
-
83
-
84
-
85
-
86
-
87
-
88
-
89
-
90
-
91
-
92
-
93
-
94
-
95
-
96
-
97
-
98
-
99
-
100
-
101
-
102
-
103
-
104
-
105
-
106
-
107
-
108
-
109
-
110
-
111
-
112
-
113
-
114
-
115
-
116
-
117
-
118
-
119
-
120
-
121
-
122
-
123
-
124
-
125
-
126
-
127
-
128
-
129
-
130
-
131
-
132
-
133
-
134
-
135
-
136
-
137
-
138
-
139
-
140
-
141
-
142
-
143
-
144
-
145
-
146
-
147
-
148
-
149
-
150
-
151
-
152
-
153
-
154
-
155
-
156
-
157
-
158
-
159
-
160
-
161
-
162
-
163
-
164
-
165
-
166
-
167
-
168
-
169
-
170
-
171
-
172
-
173
-
174
-
175
-
176
-
177
-
178
-
179
-
180
-
181
-
182
-
183
-
184
-
185
-
186
-
187
-
188
-
189
-
190
-
191
-
192
-
193
-
194
-
195
-
196
-
197
-
198
-
199
-
200
-
201
-
202
-
203
-
204
-
205
-
206
-
207
-
208
-
209
-
210
-
211
-
212
-
213
-
214
-
215
-
216
-
217
-
218
-
219
-
220
-
221
-
222
-
223
-
224
-
225
-
226
-
227
-
228
-
229
-
230
-
231
-
232
-
233
-
234
-
235
-
236
-
237
-
238
-
239
-
240
-
241
-
242
-
243
-
244
-
245
-
246
-
247
-
248
-
249
-
250
-
251
-
252
-
253
-
254
-
255
-
256
-
257
-
258
-
259
-
260
-
261
-
262
-
263
-
264
-
265
-
266
-
267
-
268
-
269
-
270
-
271
-
272
-
273
-
274
-
275
-
276
-
277
-
278
-
279
-
280
-
281
-
282
-
283
-
284
-
285
-
286
-
287
-
288
-
289
-
290
-
291
-
292
-
293
-
294
-
295
-
296
-
297
-
298
-
299
-
300
-
301
-
302
-
303
-
304
-
305
-
306
-
307
-
308
-
309
-
310
-
311
-
312
-
313
-
314
-
315
-
316
-
317
-
318
-
319
-
320
-
321
-
322
-
323
-
324
-
325
-
326
-
327
-
328
-
329
-
330
-
331
-
332
-
333
-
334
-
335
-
336
-
337
-
338
-
339
-
340
-
341
-
342
-
343
-
344
-
345
-
346
-
347
-
348
-
349
-
350
-
351
-
352
-
353
-
354
-
355
-
356
-
357
-
358
-
359
-
360
-
361
-
362
-
363
-
364
-
365
-
366
-
367
-
368
-
369
-
370
-
371
-
372
-
373
-
374
-
375
-
376
-
377
-
378
-
379
-
380
-
381
-
382
-
383
-
384
-
385
-
386
-
387
-
388
-
389
-
390
-
391
-
392
-
393
-
394
-
395
-
396
-
397
-
398
-
399
-
400
-
401
-
402
-
403
-
404
-
405
-
406
-
407
-
408
-
409
-
410
-
411
-
412
-
413
-
414
-
415
-
416
-
417
-
418
-
419
-
420
-
421
-
422
-
423
-
424
-
425
-
426
-
427
-
428
-
429
-
430
-
431
-
432
-
433
-
434
-
435
-
436
-
437
-
438
-
439
-
440
-
441
-
442
-
443
-
444
-
445
-
446
-
447
-
448
-
449
-
450
-
451
-
452
-
453
-
454
-
455
-
456
-
457
-
458
-
459
-
460
-
461
-
462
-
463
-
464
-
465
-
466
-
467
-
468
-
469
-
470
-
471
-
472
-
473
-
474
-
475
-
476
-
477
-
478
-
479
-
480
-
481
-
482
-
483
-
484
-
485
-
486
-
487
-
488
-
489
-
490
-
491
-
492
-
493
-
494
-
495
-
496
-
497
-
498
-
499
-
500
-
501
-
502
-
503
-
504
-
505
-
506
-
507
-
508
-
509
-
510
-
511
-
512
-
513
-
514
-
515
-
516
-
517
-
518
-
519
-
520
-
521
-
522
-
523
-
524
-
525
-
526
-
527
-
528
-
529
-
530
-
531
-
532
-
533
-
534
-
535
-
536
-
537
-
538
-
539
-
540
-
541
-
542
-
543
-
544
-
545
-
546
-
547
-
548
import { attr, className, el, type ElementFn, on, prop } from "../../dom";
import { roundTo } from "../../math";
import { type Preferences } from "../../preferences";
import { compute, Signal } from "../../signal";
import { choice, styles as choiceStyles } from "./choice";
export const styles =
/* css */ `
.pp-root {
overflow-y: auto;
}
.pp-section-header {
display: block;
margin: 0;
margin-top: var(--spacing_5);
margin-bottom: var(--spacing_1);
font-size: calc(var(--font-size) * 1.1);
font-weight: bold;
}
.pp-choice-list {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
gap: var(--spacing_-2);
}
.pp-input {
appearance: none;
border: 1px solid var(--action-border);
padding: var(--action-vertical-padding) var(--action-horizontal-padding);
display: inline-block;
font-size: calc(var(--font-size) * 0.9);
min-width: 6em;
background: transparent;
border-radius: var(--action-radius);
color: var(--fg);
}
.pp-input:focus {
outline: none;
}
.pp-input:focus-visible {
border-color: SelectedItem;
outline: 1px solid SelectedItem;
}
.pp-description, .pp-error {
margin: 0;
margin-top: var(--spacing_0);
font-size: calc(var(--font-size) * 0.8);
color: var(--subtle-fg);
}
.pp-error {
color: var(--error-fg);
}
` + choiceStyles;
interface PreferencesPanelProps {
preferences: Signal<Readonly<Preferences>>;
}
export function preferencesPanel({
preferences: $preferences,
}: PreferencesPanelProps): HTMLElement {
return el(
"div",
[className("pp-root")],
[
cssColorNotation($preferences),
lengthUnit($preferences),
rootFontSizeInPx($preferences),
enableColorPreview($preferences),
decimalPlaces($preferences),
viewportPanSpeed($preferences),
viewportZoomSpeed($preferences),
],
);
}
interface NumberInputProps {
$error: Signal<string | null>;
initialValue: number;
min: number;
max: number;
step?: number;
attrs?: readonly ElementFn<HTMLInputElement>[];
onChange(value: number): void;
}
function numberInput({
$error,
initialValue,
min,
max,
step = 1,
attrs = [],
onChange,
}: NumberInputProps): HTMLInputElement {
return el("input", [
...attrs,
className("pp-input"),
attr("type", "number"),
prop("value", initialValue.toString(10)),
attr("min", min.toString(10)),
attr("max", max.toString(10)),
attr("step", step.toString(10)),
attr(
"aria-invalid",
compute(() => ($error.get() ? "true" : false)),
),
on("change", (ev) => {
ev.preventDefault();
const value = parseInt((ev.currentTarget as HTMLInputElement).value, 10);
if (!Number.isFinite(value)) {
$error.set("Please input a valid number.");
return;
}
if (value < min) {
$error.set(
"Input must be greater than or equal to " + min.toString(10) + ".",
);
return;
}
if (value > max) {
$error.set(
"Input must be less than or equal to " + max.toString(10) + ".",
);
return;
}
$error.set(null);
onChange(value);
}),
]);
}
function cssColorNotation(
$preferences: Signal<Readonly<Preferences>>,
): HTMLElement {
const selected = compute(() => $preferences.get().cssColorNotation);
const onChange = (cssColorNotation: Preferences["cssColorNotation"]) => {
$preferences.set({
...$preferences.once(),
cssColorNotation,
});
};
return el(
"div",
[],
[
el("span", [className("pp-section-header")], ["CSS color notation"]),
el(
"div",
[className("pp-choice-list")],
[
choice({
value: "hex",
label: "RGB (Hex)",
selected,
group: "color_notation",
onChange,
}),
choice({
value: "rgb",
label: "RGB",
selected,
group: "color_notation",
onChange,
}),
choice({
value: "hsl",
label: "HSL",
selected,
group: "color_notation",
onChange,
}),
choice({
value: "color-srgb",
label: "sRGB",
selected,
description: [
"Display colors with ",
el("code", [], ["color"]),
" function in sRGB color space. When the Figma file is set to use Display P3 color space, ",
"color gamut would be inaccurate.",
],
group: "color_notation",
onChange,
}),
choice({
value: "display-p3",
label: "Display P3",
selected,
description: [
"Display colors in Display P3 color space. ",
"Suitable for Figma files set to use Display P3 color space. ",
"When the Figma file is set to use sRGB color space (default), resulting colors may be oversaturated. ",
"If the user environment does not support Display P3 color space, out-of-gamut colors are clamped (CSS Gamut Mapping).",
],
group: "color_notation",
onChange,
}),
choice({
value: "srgb-to-display-p3",
label: "Display P3 (sRGB range)",
selected,
description: [
"Display colors in Display P3 color space. ",
"This mode treats original color as sRGB and converts it to Display P3 color using ",
el(
"a",
[
attr(
"href",
"https://drafts.csswg.org/css-color-4/#predefined-to-predefined",
),
attr("target", "_blank"),
],
["a method described in CSS Color Module 4 draft spec"],
),
". ",
"When the Figma file is set to use Display P3 color space, resulting colors may be undersaturated. ",
"The colors generated by this mode look same regardless of whether the user environment supports Display P3 color space or not. ",
],
group: "color_notation",
onChange,
}),
],
),
],
);
}
function lengthUnit($preferences: Signal<Readonly<Preferences>>): HTMLElement {
const selected = compute(() => $preferences.get().lengthUnit);
const onChange = (lengthUnit: Preferences["lengthUnit"]) => {
$preferences.set({
...$preferences.once(),
lengthUnit,
});
};
return el(
"div",
[],
[
el("span", [className("pp-section-header")], ["CSS length unit"]),
el(
"div",
[className("pp-choice-list")],
[
choice({
value: "px",
label: "px",
selected,
group: "length_unit",
onChange,
}),
choice({
value: "rem",
label: "rem",
selected,
description: [
"Showing rem sizes to match the px sizes, assuming the ",
el("code", [], [":root"]),
" is set to ",
compute(() => {
const { rootFontSizeInPx, decimalPlaces } = $preferences.get();
return roundTo(rootFontSizeInPx, decimalPlaces).toString(10);
}),
"px. ",
"This option changes ",
el("b", [], ["every"]),
" length unit to rem, even where the use of px is preferable.",
],
group: "length_unit",
onChange,
}),
],
),
],
);
}
function rootFontSizeInPx(
$preferences: Signal<Readonly<Preferences>>,
): Signal<HTMLElement | null> {
const $isUsingRem = compute(() => $preferences.get().lengthUnit === "rem");
const $error = new Signal<string | null>(null);
return compute(() => {
if (!$isUsingRem.get()) {
return null;
}
return el(
"div",
[],
[
el(
"span",
[className("pp-section-header")],
["Root font size for rem calculation"],
),
numberInput({
$error,
initialValue: $preferences.once().rootFontSizeInPx,
min: 1,
max: 100,
attrs: [attr("aria-describedby", "root_font_size_desc")],
onChange(rootFontSizeInPx) {
$preferences.set({
...$preferences.once(),
rootFontSizeInPx,
});
},
}),
compute(() => {
const error = $error.get();
if (!error) {
return null;
}
return el("p", [className("pp-error")], [error]);
}),
el(
"p",
[attr("id", "root_font_size_desc"), className("pp-description")],
[
"Font size set to your page's ",
el("code", [], [":root"]),
" in px. ",
"When unset, 16px (default value) is the recommended value as it is the default value most browser/platform uses. ",
"When you set 62.5% (or similar) to make 1rem to match 10px, input 10px. ",
"With the current setting, 1px = ",
compute(() => {
const { rootFontSizeInPx, decimalPlaces } = $preferences.get();
return roundTo(1 / rootFontSizeInPx, decimalPlaces + 2).toString(
10,
);
}),
"rem. ",
],
),
],
);
});
}
function enableColorPreview(
$preferences: Signal<Readonly<Preferences>>,
): HTMLElement {
const selected = compute(() =>
$preferences.get().enableColorPreview ? "true" : "false",
);
const onChange = (enableColorPreview: "true" | "false") => {
$preferences.set({
...$preferences.once(),
enableColorPreview: enableColorPreview === "true",
});
};
return el(
"div",
[],
[
el("span", [className("pp-section-header")], ["CSS color preview"]),
el(
"div",
[className("pp-choice-list")],
[
choice({
value: "true",
label: "Enabled",
selected,
description: [
"Displays a color preview next to a CSS color value.",
],
group: "color_preview",
onChange,
}),
choice({
value: "false",
label: "Disabled",
selected,
description: ["Do not display color previews inside CSS code."],
group: "color_preview",
onChange,
}),
],
),
],
);
}
const ROUND_TEST_VALUE = 1.23456789123;
function decimalPlaces(
$preferences: Signal<Readonly<Preferences>>,
): HTMLElement {
const $error = new Signal<string | null>(null);
return el(
"div",
[],
[
el("span", [className("pp-section-header")], ["Decimal places"]),
numberInput({
$error,
initialValue: $preferences.once().decimalPlaces,
min: 0,
max: 10,
attrs: [attr("aria-describedby", "decimal_places_desc")],
onChange(decimalPlaces) {
$preferences.set({
...$preferences.once(),
decimalPlaces,
});
},
}),
compute(() => {
const error = $error.get();
if (!error) {
return null;
}
return el("p", [className("pp-error")], [error]);
}),
el(
"p",
[attr("id", "decimal_places_desc"), className("pp-description")],
[
"The number of decimal places to show in UI and CSS code. Some parts ignore, add to, or subtract to this number. ",
"With the current setting, ",
ROUND_TEST_VALUE.toString(10),
" would be rounded to ",
compute(() => {
const { decimalPlaces } = $preferences.get();
return (
roundTo(
ROUND_TEST_VALUE,
$preferences.get().decimalPlaces,
).toString(10) + (decimalPlaces === 0 ? " (integer)" : "")
);
}),
". ",
],
),
],
);
}
function viewportZoomSpeed(
$preferences: Signal<Readonly<Preferences>>,
): HTMLElement {
const $error = new Signal<string | null>(null);
return el(
"div",
[],
[
el("span", [className("pp-section-header")], ["Viewport zoom speed"]),
numberInput({
$error,
initialValue: $preferences.once().viewportZoomSpeed,
min: 0,
max: 999,
attrs: [attr("aria-describedby", "zoom_speed_desc")],
onChange(viewportZoomSpeed) {
$preferences.set({
...$preferences.once(),
viewportZoomSpeed,
});
},
}),
compute(() => {
const error = $error.get();
if (!error) {
return null;
}
return el("p", [className("pp-error")], [error]);
}),
el(
"p",
[attr("id", "zoom_speed_desc"), className("pp-description")],
["The speed of viewport scaling action."],
),
],
);
}
function viewportPanSpeed(
$preferences: Signal<Readonly<Preferences>>,
): HTMLElement {
const $error = new Signal<string | null>(null);
return el(
"div",
[],
[
el("span", [className("pp-section-header")], ["Viewport pan speed"]),
numberInput({
$error,
initialValue: $preferences.once().viewportPanSpeed,
min: 0,
max: 999,
attrs: [attr("aria-describedby", "pan_speed_desc")],
onChange(viewportPanSpeed) {
$preferences.set({
...$preferences.once(),
viewportPanSpeed,
});
},
}),
compute(() => {
const error = $error.get();
if (!error) {
return null;
}
return el("p", [className("pp-error")], [error]);
}),
el(
"p",
[attr("id", "pan_speed_desc"), className("pp-description")],
["The speed of viewport pan/move action."],
),
],
);
}