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.
@@ -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> {
@@ -264,7 +264,7 @@ export class Rect implements IRect {
264
264
 
265
265
  moveTo( x: number, y: number ) {
266
266
  this.left = x;
267
- this.top = x;
267
+ this.top = y;
268
268
 
269
269
  return this;
270
270
  }
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"