x4js 2.3.13 → 2.3.15

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.
@@ -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 ) {
@@ -233,7 +233,7 @@ export class Popup<P extends PopupProps = PopupProps, E extends PopupEvents = Po
233
233
  if( idx>=0 ) {
234
234
  autoclose_list.splice( idx, 1 );
235
235
  if( autoclose_list.length==0 ) {
236
- document.removeEventListener( "pointerdown", this._dismiss );
236
+ document.removeEventListener( "pointerdown", this._dismiss, true );
237
237
  }
238
238
  }
239
239
  }
@@ -263,7 +263,7 @@ export class Popup<P extends PopupProps = PopupProps, E extends PopupEvents = Po
263
263
 
264
264
  if( this.props.autoClose ) {
265
265
  if( autoclose_list.length==0 ) {
266
- document.addEventListener( "pointerdown", this._dismiss );
266
+ document.addEventListener( "pointerdown", this._dismiss, true );
267
267
  }
268
268
 
269
269
  autoclose_list.push( this );
@@ -322,6 +322,10 @@ export class Popup<P extends PopupProps = PopupProps, E extends PopupEvents = Po
322
322
 
323
323
  /**
324
324
  * binded
325
+ *
326
+ * listened on the document while the event goes down (capture): a press
327
+ * closes the popups even when what is pressed stops its propagation.
328
+ * the event is left as it is, what is pressed handles it as usual.
325
329
  */
326
330
 
327
331
  private _dismiss = ( e: UIEvent ) => {
@@ -330,9 +334,6 @@ export class Popup<P extends PopupProps = PopupProps, E extends PopupEvents = Po
330
334
  return;
331
335
  }
332
336
 
333
- e.preventDefault( );
334
- e.stopPropagation( );
335
-
336
337
  this.dismiss( );
337
338
  }
338
339
 
@@ -368,7 +369,7 @@ export class Popup<P extends PopupProps = PopupProps, E extends PopupEvents = Po
368
369
  const list = inc_group.reverse( );
369
370
  autoclose_list = excl_group;
370
371
  if( autoclose_list.length==0 ) {
371
- document.removeEventListener( "pointerdown", this._dismiss );
372
+ document.removeEventListener( "pointerdown", this._dismiss, true );
372
373
  }
373
374
 
374
375
  list.forEach( x => x.close() );
@@ -12,8 +12,12 @@
12
12
  --propertygrid-odd-background: var(--color-primary-a5);
13
13
  --propertygrid-even-background: transparent;
14
14
  --propertygrid-disabled-background: rgb(170 170 170 / 14%);
15
+ --propertygrid-hover-background: var(--color-primary-a10);
16
+ --propertygrid-focus-background: var(--color-primary-a20);
17
+ --propertygrid-input-background: rgba(255,255,255,0.6);
15
18
 
16
19
  --propertygrid-group-background: var( --color-primary-a5 );
20
+ --propertygrid-group-color: var( --color-primary-a50 );
17
21
  }
18
22
 
19
23
  .x4propertygrid {
@@ -36,19 +40,23 @@
36
40
  margin: 0;
37
41
  }
38
42
 
43
+ // Two columns shared by all the rows: the titles, as wide as the longest
44
+ // of them but never more than half of the grid, and the editors, which
45
+ // take the rest.
39
46
  .root {
40
47
  display: grid;
41
- grid-template-columns: 1fr;
48
+ grid-template-columns: fit-content( 50% ) 1fr;
42
49
  grid-template-rows: auto;
43
50
  }
44
51
 
45
52
  .group {
53
+ grid-column: 1 / -1;
46
54
  padding: 6px 4px 4px 4px;
47
55
  min-height: 2em;
48
56
  background-color: var( --propertygrid-group-background );
49
57
  margin-bottom: 8px;
50
58
  box-shadow: 4px 3px 8px rgba(0, 0, 0, 0.4);
51
- color: var(--color-primary-a50);
59
+ color: var( --propertygrid-group-color );
52
60
  display: flex;
53
61
  align-items: center;
54
62
  padding: 8px;
@@ -75,14 +83,14 @@
75
83
 
76
84
  &>.x4button {
77
85
  background-color: transparent;
78
- color: var(--color-primary-a50);
86
+ color: var( --propertygrid-group-color );
79
87
  }
80
-
88
+
81
89
  &>.x4label {
82
90
  .x4icon {
83
91
  .fa-secondary,
84
92
  .fa-primary {
85
- fill: var( --color-primary-a50 );
93
+ fill: var( --propertygrid-group-color );
86
94
  }
87
95
 
88
96
  width: 1em;
@@ -103,52 +111,62 @@
103
111
  }
104
112
 
105
113
  &:not( :last-child ) {
106
- border-bottom: 1px solid var(--color-primary-a50);
114
+ border-bottom: 1px solid var( --propertygrid-group-color );
107
115
  //box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.2);
108
116
  }
109
117
  }
110
118
 
119
+ // A row has no box of its own: its two cells go straight on the columns
120
+ // of the grid, which is how all the titles get the same width. (A row is
121
+ // a fieldset: it cannot be a subgrid.) What a row looked like, background
122
+ // and bottom line, is carried by its cells.
111
123
  .row {
112
- border-bottom: 1px solid var( --border-light );
113
- //border-top: none;
114
- //border-left: 1px solid var(--color-primary-a50);
115
- //border-right: 1px solid var(--color-primary-a50);
124
+ display: contents;
125
+
126
+ > .cell {
127
+ min-width: 0;
128
+ padding: 4px;
129
+ border-bottom: 1px solid var( --border-light );
130
+
131
+ input {
132
+ background: var( --propertygrid-input-background );
133
+ }
134
+ }
116
135
 
117
- &.even:not(:disabled) {
136
+ &.even:not(:disabled) > .cell {
118
137
  background-color: var(--propertygrid-even-background)
119
138
  }
120
-
121
- &.odd:not(:disabled) {
139
+
140
+ &.odd:not(:disabled) > .cell {
122
141
  background-color: var(--propertygrid-odd-background)
123
142
  }
124
-
125
- &.odd:disabled {
143
+
144
+ &.odd:disabled > .cell {
126
145
  background-color: var(--propertygrid-disabled-background)
127
146
  }
128
-
129
- &:has(:focus) {
130
- background-color: var(--color-primary-a20);
131
- }
132
147
 
133
- &:hover {
134
- background-color: var(--color-primary-a10);
148
+ &:has(:focus) > .cell {
149
+ background-color: var( --propertygrid-focus-background );
135
150
  }
136
-
137
- .cell {
138
- flex-grow: 1;
139
- flex-basis: 10px;
140
- padding: 4px;
141
151
 
142
- input {
143
- background: rgba(255,255,255,0.6);
144
- }
152
+ &:hover > .cell {
153
+ background-color: var( --propertygrid-hover-background );
145
154
  }
146
155
 
147
- .cell:first-child {
156
+ > .cell:first-child {
148
157
  border-right: 1px solid var( --border );
149
158
  }
150
159
 
151
- .hdr {
160
+ // a row without title (a button): its editor takes the two columns
161
+ > .cell:only-child {
162
+ grid-column: 1 / -1;
163
+ }
164
+
165
+ // The title, centered in the height of the row. Wider than half of
166
+ // the grid, it is cut by the column and goes on several lines.
167
+ > .hdr {
168
+ display: flex;
169
+ align-items: center;
152
170
  padding: 8px;
153
171
  padding-left: 34px;
154
172
  }
@@ -23,6 +23,8 @@ import { ListboxID, ListItem } from "../listbox/listbox"
23
23
  import { Label, SimpleText } from "../label/label"
24
24
  import { asap, class_ns, isFunction, isNumber } from '../../core/core_tools';
25
25
  import { Icon, ScrollView } from '../components'
26
+ import { ColorInput } from '../colorinput/colorinput'
27
+ import { EvColorChange } from '../colorpicker/colorpicker'
26
28
 
27
29
  import icons from "../assets/icons"
28
30
 
@@ -32,12 +34,14 @@ type IValue = boolean | number | string;
32
34
  type IValueCB = ( name: string) => IValue;
33
35
 
34
36
  export interface PropertyValue {
35
- type: 'boolean' | 'number' | 'string' | 'password' | 'options' | 'label' | 'button';
37
+ type: 'boolean' | 'number' | 'string' | 'password' | 'options' | 'color' | 'label' | 'button';
36
38
  title?: string;
37
39
  desc?: string;
38
40
  name?: string;
39
41
  value: IValue | IValueCB;
40
42
  options?: ListItem[];
43
+ swatches?: string[] | ( ( ) => string[] ); // for colors: offered in the picker; a function is called when it opens
44
+ nullable?: boolean; // for colors: "no color" can be chosen, the value is then null
41
45
  callback?: ( name: string, value: any ) => void;
42
46
  live?: boolean; // for live update
43
47
  cls?: string;
@@ -67,6 +71,16 @@ export interface PropertyProps extends ComponentProps {
67
71
  * @cssvar
68
72
  * ```
69
73
  * --propertygrid-background
74
+ * --propertygrid-odd-background
75
+ * --propertygrid-even-background
76
+ * --propertygrid-disabled-background
77
+ * --propertygrid-hover-background
78
+ * --propertygrid-focus-background
79
+ * --propertygrid-input-background
80
+ * --propertygrid-group-background
81
+ * --propertygrid-group-color
82
+ * --propertygrid-gadget-background
83
+ * --propertygrid-gadget-color
70
84
  * ```
71
85
  */
72
86
 
@@ -260,10 +274,23 @@ export class PropertyGrid extends VBox {
260
274
  }
261
275
  });
262
276
  }
277
+ else if (item.type === 'color') {
278
+ editor = new ColorInput({
279
+ id: item.name,
280
+ color: value as string,
281
+ format: "hex",
282
+ picker: true,
283
+ swatches: item.swatches,
284
+ nullable: item.nullable,
285
+ change: ( e: EvColorChange ) => {
286
+ item.callback?.( item.name, e.value );
287
+ }
288
+ });
289
+ }
263
290
  else if (item.type === 'label') {
264
- editor = new Label({
291
+ editor = new Label({
265
292
  id: item.name,
266
- text: value as string,
293
+ text: value as string,
267
294
  });
268
295
  }
269
296
  else if (item.type === 'button') {
@@ -347,6 +374,12 @@ export class PropertyGrid extends VBox {
347
374
  editor.setNumValue( value as number, -2 );
348
375
  }
349
376
  }
377
+ else if (item.type === 'color') {
378
+ const editor = this.root.query<ColorInput>( '#'+item.name );
379
+ if( editor ) {
380
+ editor.setColor( value as string );
381
+ }
382
+ }
350
383
  else if (item.type === 'label') {
351
384
  const label = this.root.query<Label>( '#'+item.name );
352
385
  if( label ) {
@@ -21,6 +21,7 @@ import { CoreEvent, EventMap } from './core_events';
21
21
  import { addEvent, DOMEventHandler, GlobalDOMEvents, COMPONENT } from './core_dom';
22
22
  import { Application, EvMessage } from './core_application';
23
23
  import { makeState } from './core_state.js';
24
+ import { Shortcuts, ShortcutCallback, ShortcutOptions } from './core_shortcuts';
24
25
 
25
26
  type ComponentAttributes = Record<string,string|number|boolean>;
26
27
  type CreateComponentCallBack = ( attrs: Record<string,string> ) => ComponentContent;
@@ -70,6 +71,15 @@ export const makeUniqueComponentId = ( ) => {
70
71
  return `x4-${gen_id++}`;
71
72
  }
72
73
 
74
+ /**
75
+ * CSS properties. A number gets "px", except for the unitless properties
76
+ * (opacity, lineHeight, flexGrow...).
77
+ */
78
+
79
+ export type StyleProps = {
80
+ [K in keyof CSSStyleDeclaration]?: CSSStyleDeclaration[K] | number;
81
+ };
82
+
73
83
  /**
74
84
  * Base properties for all components.
75
85
  */
@@ -80,7 +90,7 @@ export interface ComponentProps {
80
90
  /** Namespace for SVG/MathML elements. */
81
91
  ns?: string;
82
92
  /** Inline CSS styles. */
83
- style?: Partial<CSSStyleDeclaration>;
93
+ style?: StyleProps;
84
94
  /** HTML attributes. */
85
95
  attrs?: Record<string,string|number|boolean>;
86
96
  /** Child content (components, strings, or HTML). */
@@ -160,6 +170,7 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
160
170
 
161
171
  #store: Map<string|symbol,any>;
162
172
  #pstate: any;
173
+ #shortcuts: Shortcuts;
163
174
 
164
175
  constructor(props: P) {
165
176
  super( );
@@ -685,17 +696,17 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
685
696
  * @returns The component instance for chaining.
686
697
  */
687
698
 
688
- setStyle( style: Partial<CSSStyleDeclaration> ): this {
699
+ setStyle( style: StyleProps ): this {
689
700
  const _style = (this.dom as HTMLElement).style;
690
701
 
691
702
  for( const name in style ) {
692
-
693
- let value = style[name];
703
+
704
+ let value: any = style[name];
694
705
  if( !unitless[name] && (isNumber(value) || RE_NUMBER.test(value)) ) {
695
706
  value += "px";
696
707
  }
697
708
 
698
- _style[name] = value;
709
+ (_style as any)[name] = value;
699
710
  }
700
711
 
701
712
  return this;
@@ -1160,6 +1171,40 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
1160
1171
  return null;
1161
1172
  }
1162
1173
 
1174
+ // :: SHORTCUTS ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
1175
+
1176
+ /**
1177
+ * Adds a keyboard shortcut to the component. It works while the focus
1178
+ * is in the component, and while the focus is nowhere if this component
1179
+ * is the last one with shortcuts that had it: of two views that are shown
1180
+ * together, the one the user works in answers.
1181
+ *
1182
+ * A key that a child has used (it prevented its default, or stopped its
1183
+ * propagation) is not a shortcut. Nor is a key typed in a field, unless
1184
+ * `editable` is set.
1185
+ *
1186
+ * @param keys - "Mod+C", "Shift+Mod+Z", "Delete"... or several of them. Mod is Ctrl, or Cmd on a Mac.
1187
+ * @see Application.addShortcut for the shortcuts of the whole application
1188
+ *
1189
+ * @example
1190
+ * this.addShortcut( "Mod+C", ( ) => this.copy( ) );
1191
+ * this.addShortcut( ["Mod+Y", "Shift+Mod+Z"], ( ) => this.redo( ) );
1192
+ */
1193
+
1194
+ addShortcut( keys: string | string[], callback: ShortcutCallback, options?: ShortcutOptions ): this {
1195
+ if( !this.#shortcuts ) {
1196
+ const shortcuts = this.#shortcuts = new Shortcuts( );
1197
+
1198
+ // straight on the element, not with addDOMEvent: the handlers of the
1199
+ // children are called exactly as before
1200
+ this.dom.addEventListener( "keydown", ( ev: KeyboardEvent ) => shortcuts.handle( ev ) );
1201
+ this.dom.addEventListener( "focusin", ( ) => Application.instance( ).setActiveShortcuts( this, shortcuts ) );
1202
+ }
1203
+
1204
+ this.#shortcuts.add( keys, callback, options );
1205
+ return this;
1206
+ }
1207
+
1163
1208
  // :: PERSISTENCE ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
1164
1209
 
1165
1210
  protected loadPState( name: string, defaults: Record<string, any> ): any {
@@ -1185,9 +1230,10 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
1185
1230
 
1186
1231
  const state = makeState( raw );
1187
1232
 
1233
+ // the state is what is saved, not raw: makeState works on a copy of it
1188
1234
  state.on( "change", ( ) => {
1189
1235
  this.setTimeout( key, 500, ( ) => {
1190
- Application.instance().setStorage( key, JSON.stringify( raw ) );
1236
+ Application.instance().setStorage( key, JSON.stringify( state ) );
1191
1237
  } );
1192
1238
  });
1193
1239