@iobroker/gui-components 10.2.0 → 10.2.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.
Files changed (46) hide show
  1. package/README.md +16 -1
  2. package/build/Components/ColorPicker.d.ts +94 -12
  3. package/build/Components/ColorPicker.js +567 -111
  4. package/build/Components/ColorPicker.js.map +1 -1
  5. package/build/Components/ObjectBrowser/ObjectBrowserClass.d.ts +17 -1
  6. package/build/Components/ObjectBrowser/ObjectBrowserClass.js +99 -36
  7. package/build/Components/ObjectBrowser/ObjectBrowserClass.js.map +1 -1
  8. package/build/Components/ObjectBrowser/dialogs.js +14 -5
  9. package/build/Components/ObjectBrowser/dialogs.js.map +1 -1
  10. package/build/Components/ObjectBrowser/renderLeaf.js +29 -10
  11. package/build/Components/ObjectBrowser/renderLeaf.js.map +1 -1
  12. package/build/Components/ObjectBrowser/toolbar.js +11 -10
  13. package/build/Components/ObjectBrowser/toolbar.js.map +1 -1
  14. package/build/Components/Schedule.js +176 -78
  15. package/build/Components/Schedule.js.map +1 -1
  16. package/build/Components/TreeTable.d.ts +6 -2
  17. package/build/Components/TreeTable.js +183 -103
  18. package/build/Components/TreeTable.js.map +1 -1
  19. package/build/Dialogs/Error.js +26 -5
  20. package/build/Dialogs/Error.js.map +1 -1
  21. package/build/i18n/de.json +20 -0
  22. package/build/i18n/en.json +20 -0
  23. package/build/i18n/es.json +20 -0
  24. package/build/i18n/fr.json +20 -0
  25. package/build/i18n/it.json +20 -0
  26. package/build/i18n/nl.json +20 -0
  27. package/build/i18n/pl.json +20 -0
  28. package/build/i18n/pt.json +20 -0
  29. package/build/i18n/ru.json +20 -0
  30. package/build/i18n/uk.json +20 -0
  31. package/build/i18n/zh-cn.json +20 -0
  32. package/build/index.d.ts +2 -1
  33. package/build/index.js +1 -1
  34. package/build/index.js.map +1 -1
  35. package/i18n/de.json +20 -0
  36. package/i18n/en.json +20 -0
  37. package/i18n/es.json +20 -0
  38. package/i18n/fr.json +20 -0
  39. package/i18n/it.json +20 -0
  40. package/i18n/nl.json +20 -0
  41. package/i18n/pl.json +20 -0
  42. package/i18n/pt.json +20 -0
  43. package/i18n/ru.json +20 -0
  44. package/i18n/uk.json +20 -0
  45. package/i18n/zh-cn.json +20 -0
  46. package/package.json +10 -12
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Copyright 2018-2024 Denis Haev (bluefox) <dogafox@gmail.com>
2
+ * Copyright 2018-2026 Denis Haev (bluefox) <dogafox@gmail.com>
3
3
  *
4
4
  * Licensed under the Creative Commons Attribution-NonCommercial License, Version 4.0 (the "License");
5
5
  * you may not use this file except in compliance with the License.
@@ -14,77 +14,418 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import React, { Component } from 'react';
17
- import { ChromePicker } from 'react-color';
18
- import { TextField, Menu, IconButton, Button, Box } from '@mui/material';
19
- import { Delete as IconDelete, Close as IconClose } from '@mui/icons-material';
17
+ import { RgbaColorPicker, RgbColorPicker } from 'react-colorful';
18
+ import { TextField, Popover, IconButton, Button, Box, Tooltip, MenuItem, Select } from '@mui/material';
19
+ import { Delete as IconDelete, Colorize as IconColorize } from '@mui/icons-material';
20
20
  import { I18n } from '../i18n';
21
+ const RECENT_COLORS_KEY = 'iob-color-picker-recent';
22
+ const MAX_RECENT_COLORS = 12;
23
+ /** Small checkerboard, so a semi-transparent color is recognizable as such */
24
+ const ALPHA_BACKGROUND = 'linear-gradient(45deg, #c0c0c0 25%, transparent 25%), linear-gradient(-45deg, #c0c0c0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #c0c0c0 75%), linear-gradient(-45deg, transparent 75%, #c0c0c0 75%)';
25
+ const ALPHA_BACKGROUND_SIZE = '8px 8px';
26
+ const ALPHA_BACKGROUND_POSITION = '0 0, 0 4px, 4px -4px, -4px 0px';
21
27
  const styles = {
22
- color: {
23
- width: 36,
24
- height: 14,
25
- borderRadius: 2,
26
- },
27
- delButton: {
28
- marginTop: 16,
29
- },
30
28
  swatch: {
31
29
  mt: '16px',
32
- p: '5px',
30
+ p: '4px',
33
31
  backgroundColor: 'background.paper',
34
- borderRadius: '1px',
35
- boxShadow: '0 0 0 1px rgba(0,0,0,.1)',
32
+ borderRadius: '6px',
33
+ boxShadow: '0 0 0 1px rgba(128,128,128,.35)',
36
34
  display: 'inline-block',
37
35
  cursor: 'pointer',
38
36
  verticalAlign: 'middle',
37
+ transition: 'box-shadow 0.15s ease-in-out',
38
+ '&:hover': {
39
+ boxShadow: '0 0 0 2px rgba(128,128,128,.6)',
40
+ },
39
41
  },
40
42
  swatchDisabled: {
41
43
  opacity: 0.5,
42
44
  cursor: 'default',
45
+ '&:hover': {
46
+ boxShadow: '0 0 0 1px rgba(128,128,128,.35)',
47
+ },
48
+ },
49
+ color: {
50
+ height: 14,
51
+ borderRadius: '3px',
52
+ backgroundImage: ALPHA_BACKGROUND,
53
+ backgroundSize: ALPHA_BACKGROUND_SIZE,
54
+ backgroundPosition: ALPHA_BACKGROUND_POSITION,
55
+ },
56
+ delButton: {
57
+ marginTop: 16,
58
+ },
59
+ textDense: {
60
+ mt: 0,
61
+ mb: 0,
43
62
  },
44
63
  popover: {
45
64
  '& .MuiPaper-root': {
46
- textAlign: 'right',
47
- backgroundColor: '#00000000',
65
+ borderRadius: '12px',
66
+ },
67
+ },
68
+ // The chrome around the react-colorful area
69
+ pickerWrapper: (theme) => ({
70
+ p: '12px',
71
+ backgroundColor: theme.palette.background.paper,
72
+ borderRadius: '12px',
73
+ display: 'flex',
74
+ flexDirection: 'column',
75
+ gap: '10px',
76
+ // restyle react-colorful, so it fits into the ioBroker themes
77
+ '& .react-colorful': {
78
+ width: '100%',
79
+ height: 'auto',
80
+ gap: '10px',
81
+ },
82
+ '& .react-colorful__saturation': {
83
+ borderRadius: '8px',
84
+ border: 'none',
85
+ height: 150,
48
86
  boxShadow: 'none',
49
87
  },
88
+ '& .react-colorful__hue, & .react-colorful__alpha': {
89
+ height: 14,
90
+ borderRadius: '7px',
91
+ },
92
+ '& .react-colorful__alpha': {
93
+ backgroundImage: ALPHA_BACKGROUND,
94
+ backgroundSize: ALPHA_BACKGROUND_SIZE,
95
+ backgroundPosition: ALPHA_BACKGROUND_POSITION,
96
+ },
97
+ '& .react-colorful__last-control': {
98
+ borderRadius: '7px',
99
+ },
100
+ '& .react-colorful__pointer': {
101
+ width: 18,
102
+ height: 18,
103
+ borderWidth: 2,
104
+ },
105
+ '& .react-colorful__saturation-pointer': {
106
+ width: 20,
107
+ height: 20,
108
+ },
109
+ }),
110
+ controlRow: {
111
+ display: 'flex',
112
+ alignItems: 'center',
113
+ gap: '6px',
50
114
  },
51
- popoverList: {
52
- padding: 0,
115
+ preview: {
116
+ width: 28,
117
+ height: 28,
118
+ flexShrink: 0,
119
+ borderRadius: '6px',
120
+ boxShadow: '0 0 0 1px rgba(128,128,128,.35) inset',
121
+ backgroundImage: ALPHA_BACKGROUND,
122
+ backgroundSize: ALPHA_BACKGROUND_SIZE,
123
+ backgroundPosition: ALPHA_BACKGROUND_POSITION,
53
124
  },
54
- closeButton: (theme) => ({
55
- backgroundColor: `${theme.palette.background.paper} !important`,
56
- borderRadius: '0 0 25% 25%',
57
- '&:hover': {
58
- backgroundColor: `${theme.palette.secondary.main} !important`,
125
+ valueInput: {
126
+ flexGrow: 1,
127
+ minWidth: 60,
128
+ '& .MuiInputBase-input': {
129
+ fontFamily: 'monospace',
130
+ fontSize: '0.75rem',
131
+ textOverflow: 'ellipsis',
59
132
  },
60
- }),
61
- cover: {
62
- position: 'fixed',
63
- top: 0,
64
- right: 0,
65
- bottom: 0,
66
- left: 0,
67
133
  },
68
- textDense: {
69
- mt: 0,
70
- mb: 0,
134
+ formatSelect: {
135
+ flexShrink: 0,
136
+ fontSize: '0.7rem',
137
+ opacity: 0.75,
138
+ '& .MuiSelect-select': {
139
+ py: '4px',
140
+ pl: '2px',
141
+ pr: '18px !important',
142
+ },
143
+ '& .MuiSelect-icon': {
144
+ right: 0,
145
+ },
71
146
  },
72
- picker: (theme) => ({
73
- background: `${theme.palette.background.paper} !important`,
74
- }),
75
- iconButton: {
76
- width: 16,
77
- height: 16,
147
+ sectionTitle: {
148
+ fontSize: '0.7rem',
149
+ opacity: 0.6,
150
+ textTransform: 'uppercase',
151
+ letterSpacing: '0.06em',
78
152
  },
79
- button: {
80
- width: 32,
81
- height: 32,
82
- minWidth: 32,
83
- minHeight: 32,
153
+ swatchRow: {
154
+ display: 'flex',
155
+ flexWrap: 'wrap',
156
+ gap: '4px',
157
+ mt: '4px',
158
+ },
159
+ paletteItem: {
160
+ width: 22,
161
+ height: 22,
162
+ p: 0,
163
+ minWidth: 22,
164
+ borderRadius: '5px',
165
+ boxShadow: '0 0 0 1px rgba(128,128,128,.35) inset',
166
+ backgroundImage: ALPHA_BACKGROUND,
167
+ backgroundSize: ALPHA_BACKGROUND_SIZE,
168
+ backgroundPosition: ALPHA_BACKGROUND_POSITION,
169
+ '&:hover': {
170
+ boxShadow: '0 0 0 2px rgba(128,128,128,.7) inset',
171
+ },
172
+ },
173
+ fill: {
174
+ width: '100%',
175
+ height: '100%',
176
+ borderRadius: 'inherit',
84
177
  },
85
178
  };
179
+ const DEFAULT_COLOR = { r: 0, g: 0, b: 0, a: 1 };
180
+ let canvasContext;
181
+ /**
182
+ * Normalize any CSS color notation (color names, `color()`, ...) with the help of a canvas.
183
+ *
184
+ * @param color The color string to normalize.
185
+ * @returns the normalized color as `#rrggbb`/`rgba(...)` string or null if the string is no valid color.
186
+ */
187
+ function normalizeCssColor(color) {
188
+ if (canvasContext === undefined) {
189
+ canvasContext = typeof document === 'undefined' ? null : document.createElement('canvas').getContext('2d');
190
+ }
191
+ if (!canvasContext) {
192
+ return null;
193
+ }
194
+ // `fillStyle` keeps its previous value if the assigned string is no valid color,
195
+ // so it must be probed with two different defaults
196
+ canvasContext.fillStyle = '#000000';
197
+ canvasContext.fillStyle = color;
198
+ const first = canvasContext.fillStyle;
199
+ canvasContext.fillStyle = '#ffffff';
200
+ canvasContext.fillStyle = color;
201
+ return first === canvasContext.fillStyle ? first : null;
202
+ }
203
+ /**
204
+ * Convert h, s, l (0..1) to r, g, b (0..255).
205
+ *
206
+ * @param h The hue in the range 0..1.
207
+ * @param s The saturation in the range 0..1.
208
+ * @param l The lightness in the range 0..1.
209
+ */
210
+ function hsl2rgb(h, s, l) {
211
+ if (!s) {
212
+ const v = Math.round(l * 255);
213
+ return { r: v, g: v, b: v };
214
+ }
215
+ const hue2rgb = (p, q, t) => {
216
+ let _t = t;
217
+ if (_t < 0) {
218
+ _t += 1;
219
+ }
220
+ if (_t > 1) {
221
+ _t -= 1;
222
+ }
223
+ if (_t < 1 / 6) {
224
+ return p + (q - p) * 6 * _t;
225
+ }
226
+ if (_t < 1 / 2) {
227
+ return q;
228
+ }
229
+ if (_t < 2 / 3) {
230
+ return p + (q - p) * (2 / 3 - _t) * 6;
231
+ }
232
+ return p;
233
+ };
234
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
235
+ const p = 2 * l - q;
236
+ return {
237
+ r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
238
+ g: Math.round(hue2rgb(p, q, h) * 255),
239
+ b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
240
+ };
241
+ }
242
+ /**
243
+ * Convert r, g, b (0..255) to h (0..360), s, l (0..100).
244
+ *
245
+ * @param color The color to convert.
246
+ */
247
+ function rgb2hsl(color) {
248
+ const r = color.r / 255;
249
+ const g = color.g / 255;
250
+ const b = color.b / 255;
251
+ const max = Math.max(r, g, b);
252
+ const min = Math.min(r, g, b);
253
+ const l = (max + min) / 2;
254
+ if (max === min) {
255
+ return { h: 0, s: 0, l: Math.round(l * 100) };
256
+ }
257
+ const d = max - min;
258
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
259
+ let h;
260
+ if (max === r) {
261
+ h = (g - b) / d + (g < b ? 6 : 0);
262
+ }
263
+ else if (max === g) {
264
+ h = (b - r) / d + 2;
265
+ }
266
+ else {
267
+ h = (r - g) / d + 4;
268
+ }
269
+ return { h: Math.round((h / 6) * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
270
+ }
271
+ /**
272
+ * Clamp a number into the given range.
273
+ *
274
+ * @param value The value to clamp.
275
+ * @param min The lower limit.
276
+ * @param max The upper limit.
277
+ */
278
+ function clamp(value, min, max) {
279
+ return Math.min(max, Math.max(min, value));
280
+ }
281
+ /**
282
+ * Read a color channel that may be given as `128` or as `50%`.
283
+ *
284
+ * @param value The channel as string.
285
+ * @param max The maximal value of this channel.
286
+ */
287
+ function readChannel(value, max) {
288
+ const trimmed = value.trim();
289
+ const parsed = parseFloat(trimmed);
290
+ if (Number.isNaN(parsed)) {
291
+ return 0;
292
+ }
293
+ return clamp(Math.round(trimmed.endsWith('%') ? (parsed / 100) * max : parsed), 0, max);
294
+ }
295
+ /**
296
+ * Read the alpha channel that may be given as `0.5` or as `50%`.
297
+ *
298
+ * @param value The alpha channel as string or undefined if it is not given.
299
+ */
300
+ function readAlpha(value) {
301
+ if (value === undefined || value === '') {
302
+ return 1;
303
+ }
304
+ const trimmed = value.trim();
305
+ const parsed = trimmed.endsWith('%') ? parseFloat(trimmed) / 100 : parseFloat(trimmed);
306
+ return Number.isNaN(parsed) ? 1 : clamp(parsed, 0, 1);
307
+ }
308
+ /**
309
+ * Parse any CSS color notation into `{ r, g, b, a }`.
310
+ *
311
+ * Supported are `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()`, `hsl()`, `hsla()` (comma or space
312
+ * separated, with or without the `/ alpha` part) and every color name the browser knows.
313
+ *
314
+ * @param color The color string to parse.
315
+ * @returns the parsed color or null if the string could not be interpreted.
316
+ */
317
+ export function parseColor(color) {
318
+ if (!color || typeof color !== 'string') {
319
+ return null;
320
+ }
321
+ const str = color.trim().toLowerCase();
322
+ if (!str) {
323
+ return null;
324
+ }
325
+ if (str === 'transparent') {
326
+ return { r: 0, g: 0, b: 0, a: 0 };
327
+ }
328
+ const hex = /^#([\da-f]{3,8})$/.exec(str);
329
+ if (hex) {
330
+ const h = hex[1];
331
+ if (h.length === 3 || h.length === 4) {
332
+ return {
333
+ r: parseInt(h[0] + h[0], 16),
334
+ g: parseInt(h[1] + h[1], 16),
335
+ b: parseInt(h[2] + h[2], 16),
336
+ a: h.length === 4 ? Math.round((parseInt(h[3] + h[3], 16) / 255) * 100) / 100 : 1,
337
+ };
338
+ }
339
+ if (h.length === 6 || h.length === 8) {
340
+ return {
341
+ r: parseInt(h.substring(0, 2), 16),
342
+ g: parseInt(h.substring(2, 4), 16),
343
+ b: parseInt(h.substring(4, 6), 16),
344
+ a: h.length === 8 ? Math.round((parseInt(h.substring(6, 8), 16) / 255) * 100) / 100 : 1,
345
+ };
346
+ }
347
+ return null;
348
+ }
349
+ const func = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(str);
350
+ if (func) {
351
+ // both `1, 2, 3, 0.5` and `1 2 3 / 50%` are valid
352
+ const parts = func[2]
353
+ .replace('/', ',')
354
+ .split(/[\s,]+/)
355
+ .filter(part => part !== '');
356
+ if (parts.length < 3) {
357
+ return null;
358
+ }
359
+ if (func[1].startsWith('rgb')) {
360
+ return {
361
+ r: readChannel(parts[0], 255),
362
+ g: readChannel(parts[1], 255),
363
+ b: readChannel(parts[2], 255),
364
+ a: readAlpha(parts[3]),
365
+ };
366
+ }
367
+ const hue = ((parseFloat(parts[0]) % 360) + 360) % 360;
368
+ const rgb = hsl2rgb(hue / 360, clamp(parseFloat(parts[1]) / 100, 0, 1), clamp(parseFloat(parts[2]) / 100, 0, 1));
369
+ return { ...rgb, a: readAlpha(parts[3]) };
370
+ }
371
+ // color names and everything else the browser understands
372
+ const normalized = normalizeCssColor(str);
373
+ return normalized && normalized !== str ? parseColor(normalized) : null;
374
+ }
375
+ /**
376
+ * Render `{ r, g, b, a }` in the requested notation.
377
+ *
378
+ * @param color The color to render.
379
+ * @param format The desired notation.
380
+ * @param noAlpha If true, the alpha channel is dropped.
381
+ * @returns the color as CSS string.
382
+ */
383
+ export function formatColor(color, format, noAlpha) {
384
+ const alpha = noAlpha ? 1 : (color.a ?? 1);
385
+ const opaque = alpha >= 1;
386
+ const rounded = Math.round(alpha * 100) / 100;
387
+ if (format === 'hex') {
388
+ const hex = `#${color.r.toString(16).padStart(2, '0')}${color.g.toString(16).padStart(2, '0')}${color.b.toString(16).padStart(2, '0')}`;
389
+ const hexAlpha = Math.round(alpha * 255)
390
+ .toString(16)
391
+ .padStart(2, '0');
392
+ return opaque ? hex : `${hex}${hexAlpha}`;
393
+ }
394
+ if (format === 'hsl') {
395
+ const { h, s, l } = rgb2hsl(color);
396
+ return opaque ? `hsl(${h}, ${s}%, ${l}%)` : `hsla(${h}, ${s}%, ${l}%, ${rounded})`;
397
+ }
398
+ return opaque ? `rgb(${color.r}, ${color.g}, ${color.b})` : `rgba(${color.r}, ${color.g}, ${color.b}, ${rounded})`;
399
+ }
400
+ /**
401
+ * Guess the notation of the given color string.
402
+ *
403
+ * @param color The color string.
404
+ */
405
+ function detectFormat(color) {
406
+ const str = (color || '').trim().toLowerCase();
407
+ if (str.startsWith('#')) {
408
+ return 'hex';
409
+ }
410
+ if (str.startsWith('rgb')) {
411
+ return 'rgb';
412
+ }
413
+ if (str.startsWith('hsl')) {
414
+ return 'hsl';
415
+ }
416
+ return null;
417
+ }
86
418
  /**
87
- * A color picker component.
419
+ * Read the `EyeDropper` constructor, which is not part of the DOM types yet.
420
+ */
421
+ function getEyeDropper() {
422
+ if (typeof window === 'undefined') {
423
+ return undefined;
424
+ }
425
+ return window.EyeDropper;
426
+ }
427
+ /**
428
+ * A color picker component with alpha channel, eye dropper and HEX/RGB/HSL notations.
88
429
  */
89
430
  export class ColorPicker extends Component {
90
431
  /**
@@ -94,21 +435,36 @@ export class ColorPicker extends Component {
94
435
  */
95
436
  constructor(props) {
96
437
  super(props);
438
+ const value = props.value || props.color || '';
97
439
  this.state = {
98
440
  displayColorPicker: false,
99
- color: this.props.value || this.props.color || '',
441
+ rgba: parseColor(value) || { ...DEFAULT_COLOR },
442
+ textValue: value,
443
+ format: props.format || detectFormat(value) || 'rgb',
100
444
  anchorEl: null,
445
+ recentColors: [],
101
446
  };
102
447
  }
103
- handleClick = (e) => {
104
- this.setState({
105
- displayColorPicker: !this.state.displayColorPicker,
106
- anchorEl: this.state.displayColorPicker ? null : e.currentTarget,
107
- });
108
- };
109
- handleClose = () => {
110
- this.setState({ displayColorPicker: false, anchorEl: null });
111
- };
448
+ /**
449
+ * If the props are updated from outside, they should override the state
450
+ *
451
+ * @param prevProps The previous properties.
452
+ */
453
+ componentDidUpdate(prevProps) {
454
+ const prevValue = prevProps.value ?? prevProps.color ?? '';
455
+ const value = this.props.value ?? this.props.color ?? '';
456
+ // React only on real changes from outside. If the value equals what is shown, it is the own echo.
457
+ if (prevValue !== value && value !== this.state.textValue) {
458
+ this.setState({
459
+ rgba: parseColor(value) || { ...DEFAULT_COLOR },
460
+ textValue: value,
461
+ format: this.props.format || detectFormat(value) || this.state.format,
462
+ });
463
+ }
464
+ else if (this.props.format && this.props.format !== prevProps.format) {
465
+ this.setState({ format: this.props.format });
466
+ }
467
+ }
112
468
  /**
113
469
  * Convert the given color to hex ('#rrggbb') or rgba ('rgba(r,g,b,a)') format.
114
470
  *
@@ -119,17 +475,11 @@ export class ColorPicker extends Component {
119
475
  static getColor(color, isHex) {
120
476
  if (color && typeof color === 'object') {
121
477
  const oColor = color;
122
- if (oColor.rgb) {
123
- if (isHex) {
124
- return `#${oColor.rgb.r.toString(16).padStart(2, '0')}${oColor.rgb.g.toString(16).padStart(2, '0')}${oColor.rgb.b.toString(16).padStart(2, '0')}`;
125
- }
126
- return `rgba(${oColor.rgb.r},${oColor.rgb.g},${oColor.rgb.b},${oColor.rgb.a})`;
127
- }
128
- const rColor = color;
478
+ const rColor = oColor.rgb ? oColor.rgb : color;
129
479
  if (isHex) {
130
480
  return `#${rColor.r.toString(16).padStart(2, '0')}${rColor.g.toString(16).padStart(2, '0')}${rColor.b.toString(16).padStart(2, '0')}`;
131
481
  }
132
- return `rgba(${rColor.r},${rColor.g},${rColor.b},${rColor.a})`;
482
+ return `rgba(${rColor.r},${rColor.g},${rColor.b},${rColor.a ?? 1})`;
133
483
  }
134
484
  if (typeof color === 'string') {
135
485
  return isHex ? ColorPicker.rgb2hex(color || '') : color || '';
@@ -142,7 +492,7 @@ export class ColorPicker extends Component {
142
492
  * @param rgb The color in rgb() or rgba() format. if not in this format, the color will be returned as is.
143
493
  */
144
494
  static rgb2hex(rgb) {
145
- const m = rgb.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i);
495
+ const m = /^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i.exec(rgb);
146
496
  if (m) {
147
497
  const r = parseInt(m[1], 10).toString(16).padStart(2, '0');
148
498
  const g = parseInt(m[2], 10).toString(16).padStart(2, '0');
@@ -151,72 +501,178 @@ export class ColorPicker extends Component {
151
501
  }
152
502
  return rgb;
153
503
  }
154
- handleChange = (color) => {
155
- this.setState({ color }, () => this.props.onChange && this.props.onChange(ColorPicker.getColor(color)));
156
- };
157
504
  /**
158
- * If the props are updated from outside, they should override the state
505
+ * Read the recently used colors from the local storage.
506
+ */
507
+ static readRecentColors() {
508
+ try {
509
+ const stored = window.localStorage.getItem(RECENT_COLORS_KEY);
510
+ const parsed = stored ? JSON.parse(stored) : null;
511
+ if (Array.isArray(parsed)) {
512
+ return parsed.filter((item) => typeof item === 'string').slice(0, MAX_RECENT_COLORS);
513
+ }
514
+ }
515
+ catch {
516
+ // ignore a broken or unavailable local storage
517
+ }
518
+ return [];
519
+ }
520
+ /**
521
+ * Remember the given color as recently used.
159
522
  *
160
- * @param _prevProps The previous properties.
161
- * @param prevState The previous state.
523
+ * @param color The color to remember.
162
524
  */
163
- componentDidUpdate(_prevProps, prevState) {
164
- const color = ColorPicker.getColor(this.props.color || this.props.value || '');
165
- if (color !== prevState.color) {
166
- this.setState({ color });
525
+ storeRecentColor(color) {
526
+ if (this.props.noRecentColors || !color || this.state.recentColors[0] === color) {
527
+ return;
528
+ }
529
+ const recentColors = [color, ...this.state.recentColors.filter(item => item !== color)].slice(0, MAX_RECENT_COLORS);
530
+ this.setState({ recentColors });
531
+ try {
532
+ window.localStorage.setItem(RECENT_COLORS_KEY, JSON.stringify(recentColors));
533
+ }
534
+ catch {
535
+ // ignore an unavailable local storage
167
536
  }
168
537
  }
169
- renderCustomPalette() {
170
- if (!this.props.customPalette) {
538
+ handleClick = (e) => {
539
+ const open = !this.state.displayColorPicker;
540
+ this.setState({
541
+ displayColorPicker: open,
542
+ anchorEl: open ? e.currentTarget : null,
543
+ // read the recent colors on every open, so colors picked in another instance show up too
544
+ recentColors: open && !this.props.noRecentColors ? ColorPicker.readRecentColors() : this.state.recentColors,
545
+ });
546
+ };
547
+ handleClose = () => {
548
+ this.storeRecentColor(this.state.textValue);
549
+ this.setState({ displayColorPicker: false, anchorEl: null });
550
+ };
551
+ /**
552
+ * The user moved a slider, clicked a swatch or used the eye dropper.
553
+ *
554
+ * @param rgba The new color.
555
+ */
556
+ handleColorChange = (rgba) => {
557
+ const textValue = formatColor(rgba, this.state.format, this.props.noAlpha);
558
+ this.setState({ rgba, textValue }, () => this.props.onChange?.(textValue));
559
+ };
560
+ /**
561
+ * The user typed into the input field. The text is taken as it is and only parsed for the preview.
562
+ *
563
+ * @param textValue The entered text.
564
+ */
565
+ handleTextChange = (textValue) => {
566
+ const rgba = parseColor(textValue);
567
+ this.setState({ textValue, rgba: rgba || this.state.rgba }, () => this.props.onChange?.(textValue));
568
+ };
569
+ /**
570
+ * Switch between the #rrggbb, rgba() and hsla() notations.
571
+ *
572
+ * @param format The new notation.
573
+ */
574
+ handleFormatChange = (format) => {
575
+ if (!this.state.textValue) {
576
+ this.setState({ format });
577
+ return;
578
+ }
579
+ const textValue = formatColor(this.state.rgba, format, this.props.noAlpha);
580
+ this.setState({ format, textValue }, () => this.props.onChange?.(textValue));
581
+ };
582
+ handleClear = () => {
583
+ this.setState({ textValue: '', rgba: { ...DEFAULT_COLOR } }, () => this.props.onChange?.(''));
584
+ };
585
+ handleEyeDropper = () => {
586
+ const EyeDropper = getEyeDropper();
587
+ if (!EyeDropper) {
588
+ return;
589
+ }
590
+ void new EyeDropper()
591
+ .open()
592
+ .then(result => {
593
+ const rgba = parseColor(result.sRGBHex);
594
+ if (rgba) {
595
+ // the eye dropper always returns an opaque color, so the current alpha is kept
596
+ this.handleColorChange({ ...rgba, a: this.props.noAlpha ? 1 : (this.state.rgba.a ?? 1) });
597
+ }
598
+ })
599
+ .catch(() => {
600
+ // the user aborted the selection with ESC
601
+ });
602
+ };
603
+ /**
604
+ * Render a row of predefined colors.
605
+ *
606
+ * @param colors The colors to show.
607
+ * @param title The title above the row.
608
+ */
609
+ renderSwatches(colors, title) {
610
+ if (!colors.length) {
171
611
  return null;
172
612
  }
173
- return (React.createElement("div", { style: { width: '100%', display: 'flex', flexWrap: 'wrap' } }, this.props.customPalette.map(color => (React.createElement(Button, { style: styles.button, key: color, onClick: () => {
174
- this.handleChange(color);
175
- setTimeout(() => this.handleClose(), 300);
176
- } },
177
- React.createElement("div", { style: { ...styles.iconButton, background: color } }))))));
613
+ return (React.createElement(Box, { component: "div" },
614
+ React.createElement(Box, { component: "div", sx: styles.sectionTitle }, title),
615
+ React.createElement(Box, { component: "div", sx: styles.swatchRow }, colors.map((color, i) => (React.createElement(Button, { sx: styles.paletteItem, key: `${color}_${i}`, title: color, onClick: () => {
616
+ const rgba = parseColor(color);
617
+ if (rgba) {
618
+ this.handleColorChange(rgba);
619
+ }
620
+ else {
621
+ this.handleTextChange(color);
622
+ }
623
+ } },
624
+ React.createElement(Box, { component: "div", sx: styles.fill, style: { background: color } })))))));
625
+ }
626
+ renderPicker() {
627
+ const { rgba, textValue, format } = this.state;
628
+ const Picker = this.props.noAlpha ? RgbColorPicker : RgbaColorPicker;
629
+ return (React.createElement(Box, { component: "div", sx: styles.pickerWrapper, style: { width: this.props.pickerWidth || 280 } },
630
+ React.createElement(Picker, { color: rgba, onChange: (color) => this.handleColorChange({ a: rgba.a ?? 1, ...color }) }),
631
+ React.createElement(Box, { component: "div", sx: styles.controlRow },
632
+ getEyeDropper() ? (React.createElement(Tooltip, { title: I18n.t('ra_Pick color from screen') },
633
+ React.createElement(IconButton, { size: "small", onClick: this.handleEyeDropper },
634
+ React.createElement(IconColorize, { fontSize: "small" })))) : null,
635
+ React.createElement(Box, { component: "div", sx: styles.preview },
636
+ React.createElement(Box, { component: "div", sx: styles.fill, style: { background: textValue || 'transparent' } })),
637
+ React.createElement(TextField, { variant: "standard", size: "small", sx: styles.valueInput, value: textValue, placeholder: I18n.t('ra_Select color'), onChange: e => this.handleTextChange(e.target.value) }),
638
+ React.createElement(Select, { variant: "standard", disableUnderline: true, sx: styles.formatSelect, value: format, onChange: e => this.handleFormatChange(e.target.value) },
639
+ React.createElement(MenuItem, { value: "hex" }, "HEX"),
640
+ React.createElement(MenuItem, { value: "rgb" }, "RGB"),
641
+ React.createElement(MenuItem, { value: "hsl" }, "HSL")),
642
+ React.createElement(Tooltip, { title: I18n.t('ra_Clear color') },
643
+ React.createElement("span", null,
644
+ React.createElement(IconButton, { size: "small", disabled: !textValue, onClick: this.handleClear },
645
+ React.createElement(IconDelete, { fontSize: "small" }))))),
646
+ this.renderSwatches(this.props.customPalette || [], I18n.t('ra_Palette')),
647
+ this.renderSwatches(this.props.noRecentColors ? [] : this.state.recentColors, I18n.t('ra_Recently used colors'))));
178
648
  }
179
649
  render() {
180
650
  const style = { ...(this.props.style || {}) };
181
651
  style.position = 'relative';
182
- const { color } = this.state;
652
+ const { textValue } = this.state;
653
+ const hasLabel = !!(this.props.label || this.props.name);
183
654
  return (React.createElement(Box, { component: "div", style: style, sx: this.props.sx || undefined, className: this.props.className || '' },
184
- this.props.noInputField ? null : (React.createElement(TextField, { disabled: this.props.disabled, variant: "standard", id: "ar_color_picker_name", label: this.props.label || this.props.name, value: color || '', margin: "dense", sx: {
655
+ this.props.noInputField ? null : (React.createElement(TextField, { disabled: this.props.disabled, variant: "standard", id: this.props.id || 'ar_color_picker_name', label: this.props.label || this.props.name, value: textValue, margin: "dense", sx: {
185
656
  '&.MuiFormControl-root': styles.textDense,
186
- width: color ? 'calc(100% - 80px)' : 'calc(100% - 56px)',
187
- mr: color ? undefined : 1,
188
- }, onChange: e => this.handleChange(e.target.value) })),
189
- !this.props.noInputField && color ? (React.createElement(IconButton, { disabled: this.props.disabled, onClick: () => this.handleChange(''), size: "small", style: this.props.label || this.props.name ? styles.delButton : undefined },
657
+ width: textValue ? 'calc(100% - 80px)' : 'calc(100% - 56px)',
658
+ mr: textValue ? undefined : 1,
659
+ }, onChange: e => this.handleTextChange(e.target.value) })),
660
+ !this.props.noInputField && textValue ? (React.createElement(IconButton, { disabled: this.props.disabled, onClick: this.handleClear, size: "small", style: hasLabel ? styles.delButton : undefined },
190
661
  React.createElement(IconDelete, null))) : null,
191
662
  React.createElement(Box, { component: "div", onClick: e => !this.props.disabled && this.handleClick(e), title: I18n.t('ra_Select color'), sx: {
192
663
  ...styles.swatch,
193
664
  ...(this.props.disabled ? styles.swatchDisabled : undefined),
194
- background: color ? undefined : 'transparent',
195
- border: color ? undefined : '1px dashed #ccc',
196
- boxSizing: 'border-box',
197
- marginTop: this.props.noInputField || !(this.props.label || this.props.name) ? 0 : undefined,
665
+ marginTop: this.props.noInputField || !hasLabel ? 0 : undefined,
198
666
  } },
199
- React.createElement("div", { style: {
667
+ React.createElement(Box, { component: "div", sx: {
200
668
  ...styles.color,
201
- background: ColorPicker.getColor(color),
669
+ border: textValue ? undefined : '1px dashed #888',
670
+ boxSizing: 'border-box',
671
+ }, style: {
202
672
  width: this.props.noInputField ? this.props.barWidth || 16 : this.props.barWidth || 36,
203
- } })),
204
- this.state.displayColorPicker && !this.props.disabled ? (React.createElement(Menu, { sx: {
205
- ...styles.popover,
206
- '&. MuiMenu-list': styles.popoverList,
207
- }, anchorEl: this.state.anchorEl, open: !0, onClose: () => this.handleClose() },
208
- React.createElement(ChromePicker, { color: this.state.color || undefined, onChangeComplete: (_color) => this.handleChange(_color.rgb), styles: {
209
- default: {
210
- picker: {
211
- backgroundColor: this.props.theme?.palette.background.paper || '#888',
212
- },
213
- },
214
- } }),
215
- color && this.props.noInputField ? (React.createElement(IconButton, { sx: styles.closeButton, onClick: () => this.handleChange('') },
216
- React.createElement(IconDelete, null))) : null,
217
- React.createElement(IconButton, { sx: styles.closeButton, onClick: () => this.handleClose() },
218
- React.createElement(IconClose, null)),
219
- this.renderCustomPalette())) : null));
673
+ } },
674
+ React.createElement(Box, { component: "div", sx: styles.fill, style: { background: textValue || 'transparent' } }))),
675
+ this.state.displayColorPicker && !this.props.disabled ? (React.createElement(Popover, { sx: styles.popover, anchorEl: this.state.anchorEl, open: !0, onClose: this.handleClose, anchorOrigin: { vertical: 'bottom', horizontal: 'left' }, transformOrigin: { vertical: 'top', horizontal: 'left' } }, this.renderPicker())) : null));
220
676
  }
221
677
  }
222
678
  //# sourceMappingURL=ColorPicker.js.map