x4js 2.3.14 → 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.
package/aicontext.md CHANGED
@@ -46,10 +46,11 @@ CoreElement<E> named timers + event surface (no DOM)
46
46
 
47
47
  EventSource<E> standalone event registry
48
48
  ├── DataStore<T>
49
- └── Router
49
+ ├── Router
50
+ └── StateManager<T>
50
51
 
51
52
  CoreElement-based (non-DOM): DataProxy, DataView, Store
52
- Standalone: Rect, Timer, Color, DataModel, StateManager, Stylesheet, ComputedStyle,
53
+ Standalone: Rect, Timer, Color, DataModel, Stylesheet, ComputedStyle,
53
54
  SvgBuilder, X4PDFBuilder, x4_react, CMover, UnsafeHtml (extends String)
54
55
  ```
55
56
 
@@ -1277,17 +1278,36 @@ initTooltips(): void // initialize the global
1277
1278
 
1278
1279
  ---
1279
1280
 
1280
- ## State (`core_state`) — Experimental
1281
+ ## State (`core_state`)
1281
1282
 
1282
1283
  ```ts
1283
- class StateManager {
1284
- constructor(initialState)
1285
- getState(path: string, defaultValue?): any
1286
- setState(path: string, value, context?): void
1284
+ makeState<T>(initialState: T): StateProxy<T> // the data itself + on / off / once / watch
1285
+
1286
+ const state = makeState({ count: 0, user: { name: '' }, items: [1, 2, 3] })
1287
+ state.on('change', e => ...) // EvStateChange { path, value }
1288
+ state.watch('user', e => ...) // 'user', anything below it, or a parent replaced; returns { off() }
1289
+ state.count++ // path "count"
1290
+ state.user.name = 'x' // path "user.name"
1291
+ state.items.push(4) // path "items[3]"
1292
+
1293
+ class StateManager<T> extends EventSource<StateEvents> {
1294
+ constructor(initialState: T)
1295
+ proxify(): StateProxy<T>
1296
+ on(name, listener): { off(): void }
1297
+ off(name, listener): void
1298
+ once(name, listener): { off(): void }
1299
+ watch(path: string, cb: (ev: EvStateChange) => void): { off(): void }
1287
1300
  }
1288
- // NOTE: marked @Experimental in the sources.
1301
+
1302
+ // Component (protected): a state kept in localStorage under "x4@persist:<name>",
1303
+ // saved 500 ms after a change
1304
+ loadPState(name: string, defaults: Record<string, any>): any
1289
1305
  ```
1290
1306
 
1307
+ - Plain objects and arrays are proxied when read; Date, Map, Set and class instances are not.
1308
+ - Setting a property to the value it already has fires nothing.
1309
+ - Reading a property that does not exist logs an error and returns `undefined`.
1310
+
1291
1311
  ---
1292
1312
 
1293
1313
  ## Common patterns
@@ -1487,7 +1507,6 @@ Optional keys: `external`, `define`, `esbuild`, `dev` (`host`, `port`, `https`,
1487
1507
  - The default language is `fr`. Call `selectLanguage('en')` to switch to English.
1488
1508
  - `Gridview` is backed by a `DataStore`; `Spreadsheet` is backed by a `Store` (different, cell-based).
1489
1509
  - `DataRecordID` is currently typed `any`; `strictNullChecks` is intentionally off in the recommended `tsconfig.json`.
1490
- - `StateManager` (`core_state`) is marked **Experimental** — verify before relying on it.
1491
1510
  - `MonacoEditor` requires `MonacoEditor.start()` (async) before use; register extra typings with `MonacoEditor.addTypelib(name, code)`.
1492
1511
  - `x4_react.create_element` is the JSX pragma;
1493
1512
  - `unbubbleEvents` is the set of DOM events that do not bubble through the x4 component tree.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x4js",
3
- "version": "2.3.14",
3
+ "version": "2.3.15",
4
4
  "type": "module",
5
5
  "main": "src/x4.ts",
6
6
  "module": "src/x4.ts",
@@ -86,6 +86,7 @@ export class ColorInput extends HBox<ColorInputProps,ColorInputEvents> {
86
86
  private _edit: Input;
87
87
  private _swatches: Swatches | ( ( ) => Swatches );
88
88
  private _popup: Popup; // the color picker, once it was opened
89
+ private _closedAt = -1; // when it closed for the last time (performance.now)
89
90
 
90
91
  constructor( props: ColorInputProps ) {
91
92
  super( props );
@@ -128,11 +129,12 @@ export class ColorInput extends HBox<ColorInputProps,ColorInputEvents> {
128
129
  if( props.picker ) {
129
130
  // A click on the swatch opens the picker, a second one closes it.
130
131
  // 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
+ // outside of it, before the swatch hears of it: it was open if
133
+ // it closed since this very press began.
132
134
  let wasOpen = false;
133
135
 
134
136
  this._swatch.addClass( "picker" );
135
- this._swatch.addDOMEvent( "pointerdown", ( ) => { wasOpen = !!this._popup?.isOpen( ); } );
137
+ this._swatch.addDOMEvent( "pointerdown", ev => { wasOpen = this._closedAt >= ev.timeStamp; } );
136
138
  this._swatch.addDOMEvent( "click", ( ) => {
137
139
  if( !wasOpen ) {
138
140
  this._openPicker( );
@@ -202,6 +204,7 @@ export class ColorInput extends HBox<ColorInputProps,ColorInputEvents> {
202
204
  } );
203
205
 
204
206
  const popup = this._popup = new Popup( { cls: "x4colorinput-popup", autoClose: true, content: picker } );
207
+ popup.on( "closed", ( ) => { this._closedAt = performance.now( ); } );
205
208
 
206
209
  // after the construction: the picker fires a first change when it is built
207
210
  picker.on( "change", ev => {
@@ -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() );
@@ -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;
@@ -169,6 +170,7 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
169
170
 
170
171
  #store: Map<string|symbol,any>;
171
172
  #pstate: any;
173
+ #shortcuts: Shortcuts;
172
174
 
173
175
  constructor(props: P) {
174
176
  super( );
@@ -1169,6 +1171,40 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
1169
1171
  return null;
1170
1172
  }
1171
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
+
1172
1208
  // :: PERSISTENCE ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
1173
1209
 
1174
1210
  protected loadPState( name: string, defaults: Record<string, any> ): any {
@@ -1194,9 +1230,10 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
1194
1230
 
1195
1231
  const state = makeState( raw );
1196
1232
 
1233
+ // the state is what is saved, not raw: makeState works on a copy of it
1197
1234
  state.on( "change", ( ) => {
1198
1235
  this.setTimeout( key, 500, ( ) => {
1199
- Application.instance().setStorage( key, JSON.stringify( raw ) );
1236
+ Application.instance().setStorage( key, JSON.stringify( state ) );
1200
1237
  } );
1201
1238
  });
1202
1239
 
@@ -18,6 +18,7 @@ import { Component, componentFromDOM } from './component';
18
18
  import { CoreElement } from './core_element';
19
19
  import { CoreEvent, EventMap } from './core_events';
20
20
  import { asap, getFocusableElements, ITabHandler } from './core_tools';
21
+ import { Shortcuts, ShortcutCallback, ShortcutOptions } from './core_shortcuts';
21
22
 
22
23
  const socket_sent = Symbol( 'socket' );
23
24
 
@@ -80,6 +81,9 @@ export class Application<E extends ApplicationEvents = ApplicationEvents> extend
80
81
  private mainview: Component;
81
82
  private props: AppProps;
82
83
  private mounted = false;
84
+
85
+ private _shortcuts = new Shortcuts( );
86
+ private _active: { owner: Component, shortcuts: Shortcuts };
83
87
 
84
88
  /**
85
89
  * Provides access to process-related information, such as touch capabilities.
@@ -206,9 +210,38 @@ export class Application<E extends ApplicationEvents = ApplicationEvents> extend
206
210
  ev.preventDefault( );
207
211
  }
208
212
  }
213
+
214
+ // The focus is nowhere: the shortcuts of the view the user worked in last.
215
+ // (when it is in a view, the view has seen the key before us)
216
+ if( ev.target===document.body && this._active?.owner.dom.isConnected && this._active.shortcuts.handle( ev ) ) {
217
+ return;
218
+ }
219
+
220
+ this._shortcuts.handle( ev );
209
221
  } );
210
222
  }
211
223
 
224
+ /**
225
+ * Adds a keyboard shortcut to the whole application: it works wherever
226
+ * the focus is, even in a dialog. A key typed in a field is not a
227
+ * shortcut, unless `editable` is set.
228
+ *
229
+ * @param keys - "Mod+S", "F1"... or several of them. Mod is Ctrl, or Cmd on a Mac.
230
+ * @see Component.addShortcut for the shortcuts of a view
231
+ */
232
+
233
+ addShortcut( keys: string | string[], callback: ShortcutCallback, options?: ShortcutOptions ) {
234
+ this._shortcuts.add( keys, callback, options );
235
+ }
236
+
237
+ /**
238
+ * @internal the last component with shortcuts that had the focus
239
+ */
240
+
241
+ setActiveShortcuts( owner: Component, shortcuts: Shortcuts ) {
242
+ this._active = { owner, shortcuts };
243
+ }
244
+
212
245
  /**
213
246
  * Moves focus to the next or previous focusable element within the application.
214
247
  * Handles Tab and Shift+Tab key presses.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * ___ ___ __
3
+ * \ \/ / / _
4
+ * \ / /_| |_
5
+ * / \____ _|
6
+ * /__/\__\ |_|
7
+ *
8
+ * @file core_shortcuts.ts
9
+ * @author Etienne Cochard
10
+ *
11
+ * @copyright (c) 2026 R-libre ingenierie
12
+ *
13
+ * Use of this source code is governed by an MIT-style license
14
+ * that can be found in the LICENSE file or at https://opensource.org/licenses/MIT.
15
+ **/
16
+
17
+ import { isArray } from './core_tools';
18
+
19
+ /**
20
+ * Keyboard shortcuts.
21
+ *
22
+ * A shortcut is a sequence of keys, written as it is read: "Mod+C",
23
+ * "Shift+Mod+Z", "Delete", "Alt+ArrowLeft". The modifiers are Shift, Ctrl,
24
+ * Cmd, Alt and Mod: the key of the commands, which is Ctrl on Windows and
25
+ * Linux, and Cmd on a Mac.
26
+ *
27
+ * This class only holds a list of shortcuts and finds the one a keyboard
28
+ * event is. It is used by Component.addShortcut (the shortcuts of a view,
29
+ * while the focus is in it) and by Application.addShortcut (those of the
30
+ * whole application).
31
+ */
32
+
33
+ export type ShortcutCallback = ( ev: KeyboardEvent ) => void;
34
+
35
+ export interface ShortcutOptions {
36
+ /** also while the user types in a field (Mod+S, F5...) */
37
+ editable?: boolean;
38
+ }
39
+
40
+ interface Shortcut {
41
+ sequence: string;
42
+ callback: ShortcutCallback;
43
+ editable: boolean;
44
+ }
45
+
46
+ interface Keys {
47
+ shift: boolean;
48
+ ctrl: boolean;
49
+ cmd: boolean;
50
+ alt: boolean;
51
+ key: string;
52
+ }
53
+
54
+ const isMac = typeof navigator!=="undefined" && /mac|iphone|ipad/i.test( navigator.platform ?? "" );
55
+
56
+ /**
57
+ * "Shift+Mod+K" -> shift, ctrl (cmd on a Mac), k
58
+ */
59
+
60
+ function parse( keys: string ): Keys {
61
+ const parts = keys.toLowerCase( ).split( "+" ).map( p => p.trim( ) );
62
+ const key = parts.pop( ) || "+"; // "Ctrl++": the key is +
63
+ const has = ( name: string ) => parts.includes( name );
64
+
65
+ return {
66
+ shift: has( "shift" ),
67
+ ctrl: has( "ctrl" ) || ( has( "mod" ) && !isMac ),
68
+ cmd: has( "cmd" ) || ( has( "mod" ) && isMac ),
69
+ alt: has( "alt" ),
70
+ key
71
+ };
72
+ }
73
+
74
+ /**
75
+ * always in the same order: "shift+ctrl+cmd+alt+key"
76
+ */
77
+
78
+ function join( k: Keys ): string {
79
+ return ( k.shift ? "shift+" : "" ) + ( k.ctrl ? "ctrl+" : "" ) + ( k.cmd ? "cmd+" : "" ) + ( k.alt ? "alt+" : "" ) + k.key;
80
+ }
81
+
82
+ function sequenceOf( ev: KeyboardEvent ): string {
83
+ // AltGr writes characters, never a command (Windows reports it as ctrl + alt)
84
+ if( ev.getModifierState( "AltGraph" ) ) {
85
+ return null;
86
+ }
87
+
88
+ // a keyboard without latin letters: the key where the letter is on a latin one
89
+ let key = ev.key.toLowerCase( );
90
+ if( key.length===1 && ( key<"a" || key>"z" ) && /^Key[A-Z]$/.test( ev.code ) ) {
91
+ key = ev.code[3].toLowerCase( );
92
+ }
93
+
94
+ return join( { shift: ev.shiftKey, ctrl: ev.ctrlKey, cmd: ev.metaKey, alt: ev.altKey, key } );
95
+ }
96
+
97
+ function isEditable( target: EventTarget ): boolean {
98
+ const el = target as HTMLElement;
99
+ return el.isContentEditable || el.tagName==="INPUT" || el.tagName==="TEXTAREA" || el.tagName==="SELECT";
100
+ }
101
+
102
+ /**
103
+ *
104
+ */
105
+
106
+ export class Shortcuts {
107
+
108
+ private list: Shortcut[] = [];
109
+
110
+ /**
111
+ * @param keys - a sequence, or several ones for the same action
112
+ * @example
113
+ * shortcuts.add( "Mod+C", ( ) => this.copy( ) );
114
+ * shortcuts.add( ["Mod+Y", "Shift+Mod+Z"], ( ) => this.redo( ) );
115
+ */
116
+
117
+ add( keys: string | string[], callback: ShortcutCallback, options: ShortcutOptions = {} ) {
118
+ for( const k of isArray( keys ) ? keys : [keys] ) {
119
+ this.list.push( { sequence: join( parse( k ) ), callback, editable: !!options.editable } );
120
+ }
121
+ }
122
+
123
+ clear( ) {
124
+ this.list = [];
125
+ }
126
+
127
+ /**
128
+ * calls the shortcut a key is, if any
129
+ * @returns true when it was one
130
+ */
131
+
132
+ handle( ev: KeyboardEvent ): boolean {
133
+ // already used by what has the focus (Enter on a button...)
134
+ if( ev.defaultPrevented ) {
135
+ return false;
136
+ }
137
+
138
+ const sequence = sequenceOf( ev );
139
+ const found = this.list.find( s => s.sequence===sequence && ( s.editable || !isEditable( ev.target ) ) );
140
+ if( !found ) {
141
+ return false;
142
+ }
143
+
144
+ ev.preventDefault( );
145
+ found.callback( ev );
146
+ return true;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * how the keys with a name are written
152
+ */
153
+
154
+ const key_names: Record<string,string> = {
155
+ arrowleft: "←",
156
+ arrowup: "↑",
157
+ arrowright: "→",
158
+ arrowdown: "↓",
159
+ escape: "Esc",
160
+ delete: "Del",
161
+ " ": "Space",
162
+ };
163
+
164
+ /**
165
+ * How a sequence is written for the user, in a tooltip or a menu:
166
+ * "Ctrl+C", "Ctrl+Shift+Z", and on a Mac the signs of its keys.
167
+ *
168
+ * @example
169
+ * new Button( { icon, tooltip: `Undo (${shortcutText( "Mod+Z" )})` } )
170
+ */
171
+
172
+ export function shortcutText( keys: string ): string {
173
+ const k = parse( keys );
174
+ const key = key_names[k.key] ?? ( k.key[0].toUpperCase( ) + k.key.substring( 1 ) );
175
+
176
+ if( isMac ) {
177
+ return ( k.ctrl ? "⌃" : "" ) + ( k.alt ? "⌥" : "" ) + ( k.shift ? "⇧" : "" ) + ( k.cmd ? "⌘" : "" ) + key;
178
+ }
179
+
180
+ return ( k.ctrl ? "Ctrl+" : "" ) + ( k.cmd ? "Meta+" : "" ) + ( k.alt ? "Alt+" : "" ) + ( k.shift ? "Shift+" : "" ) + key;
181
+ }
@@ -139,9 +139,13 @@ export class StateManager<T extends State> extends EventSource<StateEvents> {
139
139
  return Reflect.get( target, prop, receiver );
140
140
  }
141
141
 
142
- // genuinely missing property ('in' walks the prototype chain, so array methods, toString, toJSON... never trigger a false positive)
142
+ // genuinely missing property ('in' walks the prototype chain, so array methods, toString... never trigger a false positive).
143
+ // toJSON is asked by JSON.stringify to every object: a plain one has none, and that is not an error
143
144
  if( !( prop in target ) ) {
144
- console.error( `state error, unable to find ${_child_path(path,prop,target)}` );
145
+ if( prop !== "toJSON" ) {
146
+ console.error( `state error, unable to find ${_child_path(path,prop,target)}` );
147
+ }
148
+
145
149
  return undefined;
146
150
  }
147
151
 
@@ -237,7 +241,7 @@ export class StateManager<T extends State> extends EventSource<StateEvents> {
237
241
  * const state = makeState( { count: 0, items: [1,2,3] } );
238
242
  * state.on( "change", e => console.log( e.path, "=", e.value ) );
239
243
  * state.count++; // "count = 1"
240
- * state.items.push( 4 ); // "items.3 = 4"
244
+ * state.items.push( 4 ); // "items[3] = 4"
241
245
  */
242
246
 
243
247
  export function makeState<T extends Record<keyof T, StateData>>( initialState: T ): StateProxy<T> {
package/src/x4.d.ts CHANGED
@@ -25,6 +25,7 @@ export * from "./core/core_i18n";
25
25
  export * from "./core/core_pdf";
26
26
  export * from "./core/core_react";
27
27
  export * from "./core/core_router";
28
+ export * from "./core/core_shortcuts";
28
29
  export * from "./core/core_state";
29
30
  export * from "./core/core_styles";
30
31
  export * from "./core/core_svg";
package/src/x4.ts CHANGED
@@ -26,6 +26,7 @@ export * from "./core/core_i18n"
26
26
  export * from "./core/core_pdf"
27
27
  export * from "./core/core_react"
28
28
  export * from "./core/core_router"
29
+ export * from "./core/core_shortcuts"
29
30
  export * from "./core/core_state"
30
31
  export * from "./core/core_styles"
31
32
  export * from "./core/core_svg"