x4js 2.3.12 → 2.3.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.
@@ -1,40 +1,70 @@
1
- /**
1
+ /**
2
2
  * ___ ___ __
3
3
  * \ \/ / / _
4
4
  * \ / /_| |_
5
- * / \____ _|
5
+ * / \____ _|
6
6
  * /__/\__\ |_|
7
- *
7
+ *
8
8
  * @file colorinput.ts
9
- * @author Etienne Cochard
10
- *
9
+ * @author Etienne Cochard
10
+ *
11
11
  * @copyright (c) 2026 R-libre ingenierie
12
12
  *
13
- * Use of this source code is governed by an MIT-style license
13
+ * Use of this source code is governed by an MIT-style license
14
14
  * that can be found in the LICENSE file or at https://opensource.org/licenses/MIT.
15
15
  **/
16
16
 
17
- import { Component } from '../../core/component';
18
- import { isFeatureAvailable, class_ns } from '../../core/core_tools';
17
+ import { Component, ComponentEvents } from '../../core/component';
18
+ import { EventCallback } from '../../core/core_events';
19
+ import { isFeatureAvailable, class_ns, isFunction, IComponentInterface, IFormElement } from '../../core/core_tools';
19
20
  import { Color } from '../../core/core_colors';
20
21
 
21
22
  import { BoxProps, HBox } from '../boxes/boxes';
22
23
  import { Input } from '../input/input';
23
24
  import { Button } from '../button/button';
25
+ import { Popup } from '../popup/popup';
26
+ import { ColorPicker, EvColorChange } from '../colorpicker/colorpicker';
24
27
 
25
28
 
26
29
  import "./colorinput.module.scss"
27
30
  import icon from "./crosshairs-simple-sharp-light.svg"
28
31
 
29
- //TODO: add swatches
30
32
  //TODO: better keyboard handling (selection after cursor)
31
33
 
34
+ type Swatches = ( Color | string )[];
35
+
32
36
  /**
33
- *
37
+ *
34
38
  */
35
39
 
36
- interface ColorInputProps extends BoxProps {
40
+ export interface ColorInputProps extends BoxProps {
41
+ /** The color at start. null: no color is shown. */
37
42
  color: Color | string;
43
+
44
+ /** Name of the field: with it, a Form reads and writes the color (as "#rrggbb"). */
45
+ name?: string;
46
+
47
+ /** How the field writes the color. "rgb" by default: rgb(r,g,b). "hex": #rrggbb. */
48
+ format?: "rgb" | "hex";
49
+
50
+ /** A click on the swatch opens a color picker. */
51
+ picker?: boolean;
52
+
53
+ /**
54
+ * Colors offered in the picker. A function is called each time the
55
+ * picker opens: the list may change with time.
56
+ */
57
+ swatches?: Swatches | ( ( ) => Swatches );
58
+
59
+ /** The field may have no color: emptied, or "no color" picked. */
60
+ nullable?: boolean;
61
+
62
+ /** The user changed the color (not fired by setColor). */
63
+ change?: EventCallback<EvColorChange>;
64
+ }
65
+
66
+ interface ColorInputEvents extends ComponentEvents {
67
+ change: EvColorChange;
38
68
  }
39
69
 
40
70
  /**
@@ -43,56 +73,196 @@ interface ColorInputProps extends BoxProps {
43
73
  * --colorinput-btn-background
44
74
  * --colorinput-btn-color
45
75
  * --colorinput-btn-color-hover
76
+ * --colorinput-popup-background
46
77
  * ```
47
78
  */
48
79
 
49
80
  @class_ns( "x4" )
50
- export class ColorInput extends HBox<ColorInputProps> {
81
+ export class ColorInput extends HBox<ColorInputProps,ColorInputEvents> {
51
82
  static "$cls-ns" = "x4";
52
83
 
84
+ private _color: Color; // null: no color
85
+ private _swatch: Component;
86
+ private _edit: Input;
87
+ private _swatches: Swatches | ( ( ) => Swatches );
88
+ private _popup: Popup; // the color picker, once it was opened
89
+
53
90
  constructor( props: ColorInputProps ) {
54
91
  super( props );
55
92
 
56
- let swatch: Component;
57
- let edit: Input;
93
+ this.mapPropEvents( props, "change" );
94
+ this._swatches = props.swatches;
95
+
96
+ if( props.name ) {
97
+ this.setAttribute( "name", props.name );
98
+ }
58
99
 
59
100
  this.setContent( [
60
- swatch = new Component( { cls: "swatch" } ),
61
- edit = new Input( { type: "text", value: "", spellcheck: false } ),
101
+ this._swatch = new Component( { cls: "swatch" } ),
102
+ this._edit = new Input( { type: "text", value: "", spellcheck: false } ),
62
103
 
63
104
  isFeatureAvailable("eyedropper") ? new Button( { icon: icon, click: ( ) => {
64
105
  const eyeDropper = new (window as any).EyeDropper();
65
106
  eyeDropper.open( ).then( ( result: any ) => {
66
- color = new Color( result.sRGBHex );
67
- updateColor( color );
107
+ this._changed( new Color( result.sRGBHex ) );
68
108
  }).catch( (_: any ) => {
69
109
  /* silence */
70
110
  });
71
111
  } } ) : null
72
112
  ])
73
113
 
74
- edit.addDOMEvent( "input", ( ) => {
75
- const txt = edit.getValue( );
114
+ this._edit.addDOMEvent( "input", ( ) => {
115
+ const txt = this._edit.getValue( );
116
+
117
+ if( this.props.nullable && txt.trim( )=="" ) {
118
+ this._changed( null, false );
119
+ return;
120
+ }
121
+
76
122
  const clr = new Color( txt );
77
123
  if( !clr.isInvalid() ) {
78
- color = clr;
79
- updateColor( color );
124
+ this._changed( clr );
80
125
  }
81
126
  });
82
127
 
83
- const updateColor = ( clr: Color ) => {
84
- swatch.setStyleValue( "backgroundColor", clr.toRgbString(false) );
85
- edit.setValue( clr.toRgbString(false) );
128
+ if( props.picker ) {
129
+ // A click on the swatch opens the picker, a second one closes it.
130
+ // The picker closes by itself as soon as the button goes down
131
+ // outside of it: whether it was open is to be known before that.
132
+ let wasOpen = false;
133
+
134
+ this._swatch.addClass( "picker" );
135
+ this._swatch.addDOMEvent( "pointerdown", ( ) => { wasOpen = !!this._popup?.isOpen( ); } );
136
+ this._swatch.addDOMEvent( "click", ( ) => {
137
+ if( !wasOpen ) {
138
+ this._openPicker( );
139
+ }
140
+ } );
141
+ }
142
+
143
+ this._show( this._parse( props.color ) );
144
+ }
145
+
146
+ private _parse( color: Color | string ): Color {
147
+ if( color instanceof Color ) {
148
+ return color;
86
149
  }
87
150
 
88
- let color: Color;
89
- if( props.color instanceof Color ) {
90
- color = props.color;
151
+ // Nothing to show (a color that is not known, or several ones). Whether
152
+ // the user may choose "no color" is another matter: props.nullable.
153
+ if( color===null || color===undefined || color==="" ) {
154
+ return null;
155
+ }
156
+
157
+ return new Color( color );
158
+ }
159
+
160
+ private _format( color: Color ): string {
161
+ return this.props.format=="hex" ? color.toHexString( ) : color.toRgbString( false );
162
+ }
163
+
164
+ /**
165
+ * show a color: swatch and text
166
+ * @param text false while the user empties the field: the text is left alone
167
+ */
168
+
169
+ private _show( color: Color, text = true ) {
170
+ this._color = color;
171
+
172
+ this._swatch.setClass( "none", !color );
173
+ this._swatch.setStyleValue( "backgroundColor", color ? color.toRgbString(false) : null );
174
+
175
+ if( text ) {
176
+ this._edit.setValue( color ? this._format( color ) : "" );
91
177
  }
92
- else {
93
- color = new Color( props.color );
178
+ }
179
+
180
+ /**
181
+ * the user changed the color
182
+ */
183
+
184
+ private _changed( color: Color, text = true ) {
185
+ this._show( color, text );
186
+ this.fire( "change", { color, value: color ? color.toHexString( ) : null } );
187
+ }
188
+
189
+ /**
190
+ * The color picker, with the swatches, under the field. It closes on
191
+ * a click outside of it.
192
+ */
193
+
194
+ private _openPicker( ) {
195
+ const swatches = isFunction( this._swatches ) ? this._swatches( ) : this._swatches;
196
+
197
+ // the picker works on its own color: it changes it in place
198
+ const picker = new ColorPicker( {
199
+ color: this._color ? new Color( this._color.toHexString( ) ) : new Color( 255, 255, 255 ),
200
+ swatches,
201
+ nullable: this.props.nullable,
202
+ } );
203
+
204
+ const popup = this._popup = new Popup( { cls: "x4colorinput-popup", autoClose: true, content: picker } );
205
+
206
+ // after the construction: the picker fires a first change when it is built
207
+ picker.on( "change", ev => {
208
+ this._changed( ev.color ? new Color( ev.value ) : null );
209
+
210
+ if( !ev.color ) {
211
+ popup.close( );
212
+ }
213
+ } );
214
+
215
+ popup.displayNear( this.getBoundingRect( ), "top left", "bottom left", { x: 0, y: 6 } );
216
+ }
217
+
218
+ /**
219
+ * The current color, null when there is none.
220
+ */
221
+
222
+ getColor( ): Color {
223
+ return this._color;
224
+ }
225
+
226
+ /**
227
+ * The current color as "#rrggbb" ("#rrggbbaa" when it is not opaque),
228
+ * null when there is none.
229
+ */
230
+
231
+ getValue( ): string {
232
+ return this._color ? this._color.toHexString( ) : null;
233
+ }
234
+
235
+ /**
236
+ * Shows another color, or none (null). Does not fire `change`.
237
+ */
238
+
239
+ setColor( color: Color | string ) {
240
+ this._show( this._parse( color ) );
241
+ }
242
+
243
+ /**
244
+ * Changes the colors offered in the picker (see props.swatches).
245
+ */
246
+
247
+ setSwatches( swatches: Swatches | ( ( ) => Swatches ) ) {
248
+ this._swatches = swatches;
249
+ }
250
+
251
+ /**
252
+ *
253
+ */
254
+
255
+ override queryInterface<T extends IComponentInterface>( name: string ): T {
256
+ if( name=="form-element" ) {
257
+ const i: IFormElement = {
258
+ getRawValue: ( ): any => { return this.getValue( ); },
259
+ setRawValue: ( v: any ) => { this.setColor( v ); },
260
+ isValid: ( ) => { return true; }
261
+ };
262
+
263
+ return i as unknown as T;
94
264
  }
95
265
 
96
- updateColor( color );
266
+ return super.queryInterface( name );
97
267
  }
98
- }
268
+ }
@@ -130,4 +130,27 @@
130
130
  }
131
131
  }
132
132
  }
133
+
134
+ // the colors offered under the sliders (props.swatches)
135
+ .swatches {
136
+ flex-wrap: wrap;
137
+ gap: 4px;
138
+
139
+ .item {
140
+ width: 18px;
141
+ height: 18px;
142
+ border: 1px solid var( --border );
143
+ border-radius: 3px;
144
+ cursor: pointer;
145
+
146
+ &:hover {
147
+ border-color: var( --border-focus );
148
+ }
149
+
150
+ // no color: crossed out
151
+ &.none {
152
+ background: linear-gradient( to top right, transparent 45%, #d04040 45%, #d04040 55%, transparent 55% );
153
+ }
154
+ }
155
+ }
133
156
  }
@@ -17,13 +17,19 @@
17
17
  import { Color, Hsv } from '../../core/core_colors';
18
18
  import { Rect, clamp, class_ns, isFeatureAvailable } from '../../core/core_tools';
19
19
 
20
- import { Component, ComponentEvent, ComponentEvents, ComponentProps } from '../../core/component';
20
+ import { Component, ComponentEvent, ComponentEvents, ComponentProps, EvChange } from '../../core/component';
21
21
  import { Box, BoxProps, HBox, VBox } from '../boxes/boxes';
22
22
 
23
23
  import "./colorpicker.module.scss"
24
24
 
25
- interface ColorPickerProps extends ComponentProps {
25
+ export interface ColorPickerProps extends ComponentProps {
26
26
  color: string | Color;
27
+
28
+ /** Colors offered under the sliders, to be picked with a click. */
29
+ swatches?: ( string | Color )[];
30
+
31
+ /** Adds a "no color" swatch: picking it fires `change` with a null color. */
32
+ nullable?: boolean;
27
33
  }
28
34
 
29
35
  interface HueChangeEvent extends ComponentEvent {
@@ -127,6 +133,18 @@ export class Saturation extends Box<BoxProps,CommonEvents> {
127
133
  this.color.setStyleValue( "backgroundColor", base.toRgbString(false) );
128
134
  }
129
135
 
136
+ /**
137
+ * Shows another color: base color and thumb. Does not fire `sat_change`.
138
+ */
139
+
140
+ setColor( hsv: Hsv ) {
141
+ this.hsv.saturation = hsv.saturation;
142
+ this.hsv.value = hsv.value;
143
+
144
+ this.updateBaseColor( hsv );
145
+ this.updateThumbMarker( );
146
+ }
147
+
130
148
  move( sens: string, delta: number ) {
131
149
  switch( sens ) {
132
150
  case 'saturation': {
@@ -335,31 +353,41 @@ class AlphaSlider extends Box<BoxProps,CommonEvents> {
335
353
  *
336
354
  */
337
355
 
338
- interface ChangeEvent extends ComponentEvent {
356
+ /**
357
+ * Fired by ColorPicker and ColorInput when the user changes the color.
358
+ *
359
+ * `value` is the color as text: "#rrggbb", or "#rrggbbaa" when it is not
360
+ * opaque. Both are null when the user picked "no color" (`nullable`).
361
+ */
362
+
363
+ export interface EvColorChange extends EvChange {
339
364
  color: Color;
365
+ readonly value: string;
340
366
  }
341
367
 
342
368
  interface ColorPickerChangeEvents extends ComponentEvents {
343
- change: ChangeEvent
369
+ change: EvColorChange
344
370
  }
345
371
 
346
372
  /**
347
- *
373
+ *
348
374
  */
349
375
 
350
376
  @class_ns( "x4" )
351
377
  export class ColorPicker extends VBox<ColorPickerProps,ColorPickerChangeEvents> {
352
378
 
353
379
  private _base: Color;
380
+ private _hsv: Hsv;
354
381
  private _sat: Saturation;
355
382
  private _swatch: Component;
356
383
  private _hue: HueSlider;
357
384
  private _alpha: AlphaSlider;
385
+ private _swatches: HBox;
358
386
 
359
387
 
360
388
  constructor( props: ColorPickerProps ) {
361
389
  super( props );
362
-
390
+
363
391
  if( props.color instanceof Color ) {
364
392
  this._base = props.color;
365
393
  }
@@ -367,7 +395,7 @@ export class ColorPicker extends VBox<ColorPickerProps,ColorPickerChangeEvents>
367
395
  this._base = new Color( props.color );
368
396
  }
369
397
 
370
- let hsv = this._base.toHsv( );
398
+ const hsv = this._hsv = this._base.toHsv( );
371
399
 
372
400
  this.setAttribute( "tabindex", 0 );
373
401
 
@@ -385,48 +413,34 @@ export class ColorPicker extends VBox<ColorPickerProps,ColorPickerChangeEvents>
385
413
  this._swatch = new Component( { cls: "overlay" } ),
386
414
  ] } )
387
415
  ]
388
- })
416
+ }),
417
+ this._swatches = new HBox( { cls: "swatches" } ),
389
418
  ]);
390
419
 
391
420
  this._sat.on( "sat_change", ( ev ) => {
392
- hsv.saturation = ev.saturation;
393
- hsv.value = ev.value;
394
- updateColor( );
395
- this._alpha.updateBaseColor( hsv );
421
+ this._hsv.saturation = ev.saturation;
422
+ this._hsv.value = ev.value;
423
+ this._changed( );
424
+ this._alpha.updateBaseColor( this._hsv );
396
425
  } );
397
426
 
398
427
  this._hue.on( 'hue_change', ( ev ) => {
399
- hsv.hue = ev.hue;
400
- this._sat.updateBaseColor( hsv );
401
- this._alpha.updateBaseColor( hsv );
402
- updateColor( );
428
+ this._hsv.hue = ev.hue;
429
+ this._sat.updateBaseColor( this._hsv );
430
+ this._alpha.updateBaseColor( this._hsv );
431
+ this._changed( );
403
432
  } );
404
433
 
405
434
  this._alpha.on( 'alpha_change', ( ev ) => {
406
- hsv.alpha = ev.alpha;
407
- updateColor( );
435
+ this._hsv.alpha = ev.alpha;
436
+ this._changed( );
408
437
  } );
409
438
 
410
- const updateColor = ( ) => {
411
- this._base.setHsv( hsv.hue, hsv.saturation, hsv.value, hsv.alpha );
412
- this._swatch.setStyleValue( "backgroundColor", this._base.toRgbString() );
413
- this._swatch.setAttribute( "tooltip", this._base.toRgbString() );
414
-
415
- this.fire( "change", { color: this._base } );
416
- }
417
-
418
439
  if( isFeatureAvailable("eyedropper") ) {
419
440
  this._swatch.addDOMEvent( "click", ( e ) => {
420
441
  const eyeDropper = new (window as any).EyeDropper();
421
442
  eyeDropper.open( ).then( ( result: any ) => {
422
- const color = new Color( result.sRGBHex );
423
- hsv = color.toHsv( );
424
-
425
- this._alpha.setColor( hsv );
426
-
427
- this._sat.updateBaseColor( hsv );
428
- this._hue.updateHue( hsv );
429
- updateColor( );
443
+ this._pick( new Color( result.sRGBHex ) );
430
444
  }).catch( (_: any )=> {
431
445
  /* silence */
432
446
  } );
@@ -435,7 +449,107 @@ export class ColorPicker extends VBox<ColorPickerProps,ColorPickerChangeEvents>
435
449
 
436
450
  this.addDOMEvent( "keydown", ( ev ) => this._onkey( ev ) );
437
451
 
438
- updateColor( );
452
+ this.setSwatches( props.swatches );
453
+ this._changed( );
454
+ }
455
+
456
+ /**
457
+ * the sliders moved: compute the color, show it, tell it
458
+ */
459
+
460
+ private _changed( ) {
461
+ const hsv = this._hsv;
462
+
463
+ this._base.setHsv( hsv.hue, hsv.saturation, hsv.value, hsv.alpha );
464
+ this._refresh( );
465
+ this._notify( );
466
+ }
467
+
468
+ private _refresh( ) {
469
+ this._swatch.setStyleValue( "backgroundColor", this._base.toRgbString() );
470
+ this._swatch.setAttribute( "tooltip", this._base.toRgbString() );
471
+ }
472
+
473
+ private _notify( ) {
474
+ this.fire( "change", { color: this._base, value: this._base.toHexString() } );
475
+ }
476
+
477
+ /**
478
+ * the user took a color as a whole (eye dropper, swatch)
479
+ */
480
+
481
+ private _pick( color: Color ) {
482
+ this.setColor( color );
483
+ this._notify( );
484
+ }
485
+
486
+ /**
487
+ * The current color. It is the object the `change` event gives, and it
488
+ * changes with the picker: copy it to keep a value.
489
+ */
490
+
491
+ getColor( ): Color {
492
+ return this._base;
493
+ }
494
+
495
+ /**
496
+ * Shows another color. Does not fire `change`.
497
+ */
498
+
499
+ setColor( color: Color | string ) {
500
+ const rgb = ( color instanceof Color ? color : new Color( color ) ).toRgb( );
501
+
502
+ // the color itself is kept as given: going through hsv could shift it
503
+ this._base.setRgb( rgb.red, rgb.green, rgb.blue, rgb.alpha );
504
+
505
+ const hsv = this._base.toHsv( );
506
+ this._hsv.hue = hsv.hue;
507
+ this._hsv.saturation = hsv.saturation;
508
+ this._hsv.value = hsv.value;
509
+ this._hsv.alpha = hsv.alpha;
510
+
511
+ this._sat.setColor( this._hsv );
512
+ this._hue.updateHue( this._hsv );
513
+ this._alpha.setColor( this._hsv );
514
+
515
+ this._refresh( );
516
+ }
517
+
518
+ /**
519
+ * Colors offered under the sliders. A click on one of them takes it
520
+ * and fires `change`. Nothing is shown without swatches.
521
+ */
522
+
523
+ setSwatches( swatches: ( string | Color )[] ) {
524
+ const items: Component[] = [];
525
+
526
+ if( this.props.nullable ) {
527
+ items.push( new Component( {
528
+ cls: "item none",
529
+ dom_events: {
530
+ click: ( ) => this.fire( "change", { color: null, value: null } ),
531
+ },
532
+ } ) );
533
+ }
534
+
535
+ for( const swatch of swatches ?? [] ) {
536
+ const color = swatch instanceof Color ? swatch : new Color( swatch );
537
+ if( color.isInvalid( ) ) {
538
+ continue;
539
+ }
540
+
541
+ items.push( new Component( {
542
+ cls: "item",
543
+ tooltip: color.toHexString( ),
544
+ style: { backgroundColor: color.toRgbString( ) },
545
+ dom_events: {
546
+ click: ( ) => this._pick( color ),
547
+ },
548
+ } ) );
549
+ }
550
+
551
+ this._swatches.setContent( items );
552
+ this._swatches.show( items.length>0 );
439
553
  }
440
554
 
441
555
  private _onkey( ev: KeyboardEvent ) {