x4js 2.3.17 → 2.3.19

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/aicli.md +3 -3
  2. package/aicontext.md +211 -47
  3. package/cli/config.mjs +14 -7
  4. package/cli/copy-plugin.mjs +8 -0
  5. package/cli/create.mjs +8 -2
  6. package/cli/dev.mjs +4 -1
  7. package/cli/less-plugin.mjs +5 -1
  8. package/cli/main.mjs +15 -5
  9. package/cli/rawfile-plugin.mjs +23 -3
  10. package/cli/templates.mjs +5 -1
  11. package/package.json +1 -1
  12. package/src/components/calendar/calendar.module.scss +16 -1
  13. package/src/components/calendar/calendar.ts +52 -9
  14. package/src/components/checkbox/checkbox.ts +1 -0
  15. package/src/components/combobox/combobox.ts +51 -23
  16. package/src/components/dialog/dialog.ts +4 -1
  17. package/src/components/form/form.ts +9 -5
  18. package/src/components/gridview/gridview.ts +352 -176
  19. package/src/components/input/input.ts +63 -38
  20. package/src/components/keyboard/keyboard.module.scss +35 -4
  21. package/src/components/keyboard/keyboard.ts +295 -80
  22. package/src/components/listbox/listbox.ts +6 -1
  23. package/src/components/messages/messages.ts +12 -3
  24. package/src/components/popup/popup.ts +19 -20
  25. package/src/components/radio/radio.ts +8 -4
  26. package/src/components/rating/rating.ts +38 -48
  27. package/src/components/slider/slider.ts +38 -8
  28. package/src/components/spreadsheet/spreadsheet.ts +167 -132
  29. package/src/components/switch/switch.ts +2 -1
  30. package/src/components/tag/tag.ts +1 -2
  31. package/src/components/textarea/textarea.module.scss +6 -2
  32. package/src/components/textarea/textarea.ts +50 -11
  33. package/src/components/textedit/textedit.ts +12 -2
  34. package/src/core/component.ts +16 -9
  35. package/src/core/core_application.ts +8 -4
  36. package/src/core/core_clipboard.ts +239 -0
  37. package/src/core/core_data.ts +118 -49
  38. package/src/core/core_dom.ts +36 -6
  39. package/src/core/core_dragdrop.ts +11 -5
  40. package/src/core/core_element.ts +1 -1
  41. package/src/core/core_pdf.ts +696 -46
  42. package/src/core/core_router.ts +42 -44
  43. package/src/core/core_shortcuts.ts +1 -1
  44. package/src/core/core_state.ts +22 -5
  45. package/src/x4.d.ts +1 -0
  46. package/src/x4.ts +1 -0
@@ -17,7 +17,7 @@
17
17
  **/
18
18
 
19
19
  import { BaseProps } from '../input/input';
20
- import { Component, ComponentProps } from '../../core/component';
20
+ import { Component, ComponentEvents, ComponentProps, EvChange, EvFocus } from '../../core/component';
21
21
 
22
22
  import { Label } from '../label/label';
23
23
  import { VBox } from '../boxes/boxes';
@@ -29,37 +29,64 @@ import { class_ns, IFormElement } from '../../core/core_tools';
29
29
  *
30
30
  */
31
31
 
32
- interface TextAreaProps extends BaseProps {
33
- label?: string;
32
+ export interface SimpleTextAreaProps extends BaseProps {
34
33
  value?: string;
35
34
  resize?: boolean | "none" | "vertical" | "horizontal" | "both";
36
35
  readonly?: boolean;
37
36
  trim?: boolean;
38
37
  }
39
38
 
39
+ export interface TextAreaProps extends SimpleTextAreaProps {
40
+ label?: string;
41
+ }
42
+
43
+ export interface TextAreaEvents extends ComponentEvents {
44
+ focus: EvFocus;
45
+ change: EvChange;
46
+ }
47
+
40
48
 
41
49
  /**
42
- *
50
+ *
43
51
  */
44
52
 
45
53
  @class_ns( "x4" )
46
- export class SimpleTextArea extends Component<TextAreaProps> {
54
+ export class SimpleTextArea extends Component<SimpleTextAreaProps,TextAreaEvents> {
47
55
 
48
- constructor( props: TextAreaProps ) {
56
+ constructor( props: SimpleTextAreaProps ) {
49
57
  super( { ...props, tag: "textarea" } );
50
58
 
59
+ this.mapPropEvents( props, "focus", "change" );
60
+
51
61
  this.setAttribute( "name", props.name );
52
62
  if( props.value ) {
53
63
  this.setText( props.value+'' );
54
64
  }
55
65
 
66
+ // none by default (the stylesheet); true: "both", false: "none"
56
67
  if( props.resize!==undefined ) {
57
- this.setStyleValue( "resize", props.resize===false ? "none" : props.resize as string );
68
+ this.setStyleValue( "resize", props.resize===true ? "both" : props.resize===false ? "none" : props.resize );
58
69
  }
59
70
 
60
71
  if( props.readonly ) {
61
72
  this.setAttribute( "readonly", true );
62
73
  }
74
+
75
+ if( props.placeholder ) {
76
+ this.setAttribute( "placeholder", props.placeholder );
77
+ }
78
+
79
+ if( props.required ) {
80
+ this.setAttribute( "required", true );
81
+ }
82
+
83
+ if( props.autofocus ) {
84
+ this.setAttribute( "autofocus", true );
85
+ }
86
+
87
+ this.addDOMEvent( "blur", ( ) => this.fire( "focus", { focus_out: true } ) );
88
+ this.addDOMEvent( "focus", ( ) => this.fire( "focus", { focus_out: false } ) );
89
+ this.addDOMEvent( "input", ( ) => this.fire( "change", { value: this.getText( ) } ) );
63
90
  }
64
91
 
65
92
  setText( text: string ) {
@@ -79,12 +106,18 @@ export class SimpleTextArea extends Component<TextAreaProps> {
79
106
  this.dom.scrollTop = this.dom.scrollHeight;
80
107
  }
81
108
 
109
+ /** false when it is required and empty */
110
+
111
+ isValid( ) {
112
+ return !this.props.required || this.getText( )!=="";
113
+ }
114
+
82
115
  queryInterface<T>(name: string): T {
83
116
  if( name=="form-element" ) {
84
117
  const i: IFormElement = {
85
118
  getRawValue: ( ): any => { return this.getText(); },
86
119
  setRawValue: ( v: any ) => { this.setText( v ); },
87
- isValid: ( ) => { return true; }
120
+ isValid: ( ) => { return this.isValid( ); }
88
121
  };
89
122
 
90
123
  return i as unknown as T;
@@ -115,11 +148,11 @@ export class TextArea extends VBox {
115
148
  super( props );
116
149
 
117
150
 
118
- const { label, value, resize, readonly, trim, name } = props;
151
+ const { value, resize, readonly, trim, name, placeholder, required, autofocus, focus, change } = props;
119
152
 
120
153
  this.setContent( [
121
154
  new Label( { text: props.label }),
122
- this._input = new SimpleTextArea( { label,value,resize,readonly,trim,name } )
155
+ this._input = new SimpleTextArea( { value,resize,readonly,trim,name,placeholder,required,autofocus,focus,change } )
123
156
  ])
124
157
  }
125
158
 
@@ -135,12 +168,18 @@ export class TextArea extends VBox {
135
168
  this._input.scrollToBottom( );
136
169
  }
137
170
 
171
+ /** false when it is required and empty */
172
+
173
+ isValid( ) {
174
+ return this._input.isValid( );
175
+ }
176
+
138
177
  queryInterface<T>(name: string): T {
139
178
  if( name=="form-element" ) {
140
179
  const i: IFormElement = {
141
180
  getRawValue: ( ): any => { return this.getText(); },
142
181
  setRawValue: ( v: any ) => { this.setText( v ); },
143
- isValid: ( ) => { return true; }
182
+ isValid: ( ) => { return this.isValid( ); }
144
183
  };
145
184
 
146
185
  return i as unknown as T;
@@ -87,7 +87,9 @@ export class TextEdit<E extends ComponentEvents = ComponentEvents> extends HBox<
87
87
  private input: Input;
88
88
 
89
89
  constructor( props: TextEditProps ) {
90
- super( props );
90
+ // dom_events are for the input (focus, blur, keydown...): not on the box too,
91
+ // where keydown would run a second time, and focus never (it does not bubble)
92
+ super( { ...props, dom_events: undefined } );
91
93
 
92
94
  if( !props.inputId ) {
93
95
  props.inputId = makeUniqueComponentId()
@@ -104,8 +106,16 @@ export class TextEdit<E extends ComponentEvents = ComponentEvents> extends HBox<
104
106
 
105
107
 
106
108
  const iprops: InputProps = { ...props, id: props.inputId, attrs: props.inputAttrs, width: props.inputWidth };
107
- // no propagation...
109
+
110
+ // the props of the box stay on it: given to the input too, a style would be put
111
+ // twice, a tooltip twice, the height of the box on the field...
108
112
  delete iprops.cls;
113
+ delete iprops.style;
114
+ delete iprops.tooltip;
115
+ delete iprops.hidden;
116
+ delete iprops.flex;
117
+ delete iprops.stretch;
118
+ delete iprops.height;
109
119
 
110
120
  this.setContent( [
111
121
  props.label ? new HBox( { id: "label", width: props.labelWidth, content: [
@@ -234,7 +234,7 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
234
234
  this.setStyle( props.style );
235
235
  }
236
236
 
237
- if( props.content ) {
237
+ if( props.content || props.content===0 ) {
238
238
  this.setContent( props.content );
239
239
  }
240
240
 
@@ -321,7 +321,7 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
321
321
  }
322
322
 
323
323
  if( cls.indexOf(' ')>=0 ) {
324
- const ccs = cls.split( " " );
324
+ const ccs = cls.split( " " ).filter( x => x );
325
325
  this.dom.classList.remove(...ccs);
326
326
  }
327
327
  else {
@@ -358,7 +358,7 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
358
358
  }
359
359
 
360
360
  if( cls.indexOf(' ')>=0 ) {
361
- const ccs = cls.split( " " );
361
+ const ccs = cls.split( " " ).filter( x => x );
362
362
  ccs.forEach( toggle );
363
363
  }
364
364
  else {
@@ -897,7 +897,8 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
897
897
 
898
898
  /**
899
899
  * Enables or disables the component.
900
- * This sets the `disabled` attribute and also propagates the disabled state to child input elements.
900
+ * This sets or removes the `disabled` and `inert` attributes of the component itself. Its children
901
+ * are not changed: the browser makes all that is inside an `inert` element inactive.
901
902
  * @param ena - If `true`, the component is enabled; if `false`, it's disabled. Defaults to `true`.
902
903
  * @returns The component instance for chaining.
903
904
  */
@@ -1195,8 +1196,8 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
1195
1196
  if( !this.#shortcuts ) {
1196
1197
  const shortcuts = this.#shortcuts = new Shortcuts( );
1197
1198
 
1198
- // straight on the element, not with addDOMEvent: the handlers of the
1199
- // children are called exactly as before
1199
+ // straight on the element, not with addDOMEvent: it does not change
1200
+ // how the handlers of the children are called
1200
1201
  this.dom.addEventListener( "keydown", ( ev ) => shortcuts.handle( ev as KeyboardEvent ) );
1201
1202
  this.dom.addEventListener( "focusin", ( ) => Application.instance( ).setActiveShortcuts( this, shortcuts ) );
1202
1203
  }
@@ -1230,11 +1231,17 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
1230
1231
 
1231
1232
  const state = makeState( raw );
1232
1233
 
1233
- // the state is what is saved, not raw: makeState works on a copy of it
1234
+ // the state is what is saved, not raw: makeState works on a copy of it.
1235
+ // The write waits for the changes to stop. Its timer is the one of the
1236
+ // browser, not this.setTimeout: the timers of a component are cleared
1237
+ // when it leaves the DOM, and what changed just before must still be written
1238
+ let timer: ReturnType<typeof setTimeout>;
1239
+
1234
1240
  state.on( "change", ( ) => {
1235
- this.setTimeout( key, 500, ( ) => {
1241
+ clearTimeout( timer );
1242
+ timer = setTimeout( ( ) => {
1236
1243
  Application.instance().setStorage( key, JSON.stringify( state ) );
1237
- } );
1244
+ }, 500 );
1238
1245
  });
1239
1246
 
1240
1247
  this.#pstate[name] = state;
@@ -104,8 +104,10 @@ export class Application<E extends ApplicationEvents = ApplicationEvents> extend
104
104
  // eslint-disable-next-line @typescript-eslint/no-this-alias
105
105
  main_app = this;
106
106
 
107
+ this.props = props;
108
+
107
109
  const loaded = ( ) => {
108
- this.mount( props.mountPoint ?? 'body' )
110
+ this.mount( this.props.mountPoint )
109
111
  }
110
112
 
111
113
  if( document.readyState=='complete' ) {
@@ -141,13 +143,14 @@ export class Application<E extends ApplicationEvents = ApplicationEvents> extend
141
143
 
142
144
  /**
143
145
  * Sets the main view component for the application.
144
- * This component will be mounted to the DOM.
146
+ * This component will be mounted to the DOM, in `mountPoint`.
147
+ * Called once: each call sets up the keyboard (Tab, shortcuts) again.
145
148
  * @param view - The component to set as the main view.
146
149
  */
147
150
 
148
151
  setMainView( view: Component ) {
149
152
  this.mainview = view;
150
- this.mount( );
153
+ this.mount( this.props.mountPoint );
151
154
  this._setupKeyboard( );
152
155
  }
153
156
 
@@ -321,7 +324,8 @@ export class Application<E extends ApplicationEvents = ApplicationEvents> extend
321
324
  return;
322
325
  }
323
326
 
324
- if( msg_socket ) {
327
+ // send throws while the socket is connecting
328
+ if( msg_socket && opened ) {
325
329
  msg_socket.send( JSON.stringify( {
326
330
  msg: e.msg,
327
331
  params: e.params,
@@ -0,0 +1,239 @@
1
+ /**
2
+ * ___ ___ __
3
+ * \ \/ / / _
4
+ * \ / /_| |_
5
+ * / \____ _|
6
+ * /__/\__\ |_|
7
+ *
8
+ * @file core_clipboard.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
+ /**
18
+ * The clipboard of the system: a text, an image, files.
19
+ *
20
+ * There are two ways to it, and they do not give the same things.
21
+ *
22
+ * In a "copy", "cut" or "paste" event (the keys of the user, the menu of
23
+ * the browser), the browser gives its clipboard at once and asks nothing:
24
+ * setEventClipboard, getEventClipboard. It is the only way to read files.
25
+ *
26
+ * Out of an event (a menu, a button), the system is asked: setClipboard,
27
+ * getClipboard. It is asynchronous, the browser may ask the user for a
28
+ * permission or refuse, and there is nothing to ask on a page that is not
29
+ * served in https. So what the application set last is kept: it is what is
30
+ * read when the system does not answer, and copy and paste still work
31
+ * inside the application.
32
+ *
33
+ * Files cannot be written, in neither way.
34
+ */
35
+
36
+ export interface ClipboardContent {
37
+ text?: string;
38
+ /** written as a PNG, the format every browser takes: another one is converted */
39
+ image?: Blob;
40
+ }
41
+
42
+ export interface ClipboardRead extends ClipboardContent {
43
+ /** all the files, the images too; none out of an event */
44
+ files: File[];
45
+ }
46
+
47
+ /** what the application set last */
48
+ let kept: ClipboardContent = null;
49
+
50
+ function toPng( image: Blob ): Promise<Blob> {
51
+ return new Promise( ( resolve, reject ) => {
52
+ const url = URL.createObjectURL( image );
53
+ const img = document.createElement( "img" );
54
+
55
+ img.onload = ( ) => {
56
+ URL.revokeObjectURL( url );
57
+
58
+ const canvas = document.createElement( "canvas" );
59
+ canvas.width = img.naturalWidth;
60
+ canvas.height = img.naturalHeight;
61
+ canvas.getContext( "2d" ).drawImage( img, 0, 0 );
62
+
63
+ canvas.toBlob( png => png ? resolve( png ) : reject( new Error( "not an image" ) ), "image/png" );
64
+ };
65
+
66
+ img.onerror = ( ) => {
67
+ URL.revokeObjectURL( url );
68
+ reject( new Error( "not an image" ) );
69
+ };
70
+
71
+ img.src = url;
72
+ } );
73
+ }
74
+
75
+ /**
76
+ * Set the clipboard, from a menu or a button.
77
+ *
78
+ * @returns true when the system took it. When it did not, getClipboard
79
+ * still gives it back: it can be pasted in the application.
80
+ *
81
+ * @example
82
+ * setClipboard( { text: "hello" } );
83
+ * setClipboard( { image: blob, text: "the same, for what cannot paste an image" } );
84
+ */
85
+
86
+ export async function setClipboard( data: ClipboardContent ): Promise<boolean> {
87
+ kept = { text: data.text, image: data.image };
88
+
89
+ // not there on a page that is not served in https
90
+ const system = navigator.clipboard;
91
+ if( !system ) {
92
+ return false;
93
+ }
94
+
95
+ try {
96
+ if( !data.image ) {
97
+ await system.writeText( data.text ?? "" );
98
+ return true;
99
+ }
100
+
101
+ // the conversion is given as a promise: the system has to be asked
102
+ // now, while the click of the user still allows it
103
+ const parts: Record<string,Blob | Promise<Blob>> = {
104
+ "image/png": data.image.type==="image/png" ? data.image : toPng( data.image ),
105
+ };
106
+
107
+ if( data.text ) {
108
+ parts["text/plain"] = new Blob( [data.text], { type: "text/plain" } );
109
+ }
110
+
111
+ await system.write( [new ClipboardItem( parts )] );
112
+ return true;
113
+ }
114
+ catch( e ) {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Read the clipboard, from a menu or a button.
121
+ *
122
+ * @returns what the system holds. When it refuses or cannot be asked, what
123
+ * the application set last. Never files: see getEventClipboard.
124
+ *
125
+ * @example
126
+ * const data = await getClipboard( );
127
+ * if( data.text ) {
128
+ * input.setValue( data.text );
129
+ * }
130
+ */
131
+
132
+ export async function getClipboard( ): Promise<ClipboardRead> {
133
+ const system = navigator.clipboard;
134
+
135
+ if( system ) {
136
+ try {
137
+ // read gives the images too, but some browsers only have readText
138
+ if( !system.read ) {
139
+ const text = await system.readText( );
140
+ return { text: text || undefined, files: [] };
141
+ }
142
+
143
+ const data: ClipboardRead = { files: [] };
144
+
145
+ for( const item of await system.read( ) ) {
146
+ for( const type of item.types ) {
147
+ if( type==="text/plain" && !data.text ) {
148
+ const blob = await item.getType( type );
149
+ data.text = ( await blob.text( ) ) || undefined;
150
+ }
151
+ else if( type.startsWith( "image/" ) && !data.image ) {
152
+ data.image = await item.getType( type );
153
+ }
154
+ }
155
+ }
156
+
157
+ return data;
158
+ }
159
+ catch( e ) {
160
+ // refused: what the application has
161
+ }
162
+ }
163
+
164
+ if( kept ) {
165
+ return { text: kept.text, image: kept.image, files: [] };
166
+ }
167
+
168
+ return { files: [] };
169
+ }
170
+
171
+ /**
172
+ * false when getClipboard has nothing to give: the system cannot be asked,
173
+ * and the application did not set anything. What the system holds is not
174
+ * known before it is asked for.
175
+ *
176
+ * @example
177
+ * { text: _tr.global.paste, disabled: !canReadClipboard( ), click: ( ) => this.paste( ) }
178
+ */
179
+
180
+ export function canReadClipboard( ): boolean {
181
+ const system = navigator.clipboard;
182
+ if( system && ( system.read || system.readText ) ) {
183
+ return true;
184
+ }
185
+
186
+ return !!kept;
187
+ }
188
+
189
+ /**
190
+ * Set the clipboard in a "copy" or a "cut" event. The default of the event
191
+ * is prevented: the browser would copy its own selection instead.
192
+ *
193
+ * @example
194
+ * this.addDOMEvent( "copy", ev => {
195
+ * setEventClipboard( ev, { text: this.getSelectedText( ) } );
196
+ * } );
197
+ */
198
+
199
+ export function setEventClipboard( ev: ClipboardEvent, data: ClipboardContent ): void {
200
+ kept = { text: data.text, image: data.image };
201
+
202
+ ev.preventDefault( );
203
+
204
+ if( data.text!==undefined && ev.clipboardData ) {
205
+ ev.clipboardData.setData( "text/plain", data.text );
206
+ }
207
+
208
+ // the event only takes texts: the image is given to the system
209
+ if( data.image ) {
210
+ setClipboard( data );
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Read the clipboard in a "paste" event. The default of the event is left
216
+ * as it is: prevent it when what was read is used.
217
+ *
218
+ * @example
219
+ * this.addDOMEvent( "paste", ev => {
220
+ * const data = getEventClipboard( ev );
221
+ * if( data.image ) {
222
+ * ev.preventDefault( );
223
+ * this.setPicture( data.image );
224
+ * }
225
+ * } );
226
+ */
227
+
228
+ export function getEventClipboard( ev: ClipboardEvent ): ClipboardRead {
229
+ const data: ClipboardRead = { files: [] };
230
+
231
+ const transfer = ev.clipboardData;
232
+ if( transfer ) {
233
+ data.text = transfer.getData( "text/plain" ) || undefined;
234
+ data.files = Array.from( transfer.files );
235
+ data.image = data.files.find( file => file.type.startsWith( "image/" ) );
236
+ }
237
+
238
+ return data;
239
+ }