x4js 2.3.16 → 2.3.18

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.
@@ -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
+ }
@@ -1152,41 +1152,72 @@ export class DataView extends CoreElement<DataViewEventMap>
1152
1152
 
1153
1153
  protected m_props: DataViewProps;
1154
1154
 
1155
+ private m_unlink: ( ) => void; // stops listening to the store
1156
+
1155
1157
  constructor( props: DataViewProps ) {
1156
1158
  super( );
1157
1159
 
1158
1160
  this.m_props = props;
1159
1161
  this.m_store = props.store;
1160
1162
  this.m_index = null;
1161
- this.m_filter = null;
1162
- this.m_sort = null;
1163
1163
  this.m_model = this.m_store.getModel();
1164
+ this.m_filter = props.filter;
1164
1165
 
1165
- this.filter( props.filter );
1166
-
1167
- if( props.order ) {
1168
- if( isString(props.order) ) {
1169
- this.sort( [ { field: props.order, ascending: true } ] );
1170
- }
1171
- else if( isArray(props.order) ) {
1172
- this.sort( props.order );
1173
- }
1174
- else {
1175
- this.sort( [props.order] );
1176
- }
1166
+ if( !props.order ) {
1167
+ this.m_sort = null;
1168
+ }
1169
+ else if( isString(props.order) ) {
1170
+ this.m_sort = [ { field: props.order, ascending: true } ];
1171
+ }
1172
+ else if( isArray(props.order) ) {
1173
+ this.m_sort = props.order;
1177
1174
  }
1178
1175
  else {
1179
- this.sort( null );
1176
+ this.m_sort = [props.order];
1180
1177
  }
1181
1178
 
1182
- this.m_store.addListener( 'data_change', ( e ) => this._storeChange(e) );
1179
+ // listens to the store, then filters and sorts
1180
+ this.open( );
1183
1181
  }
1184
1182
 
1185
- private _storeChange( ev: EvDataChange ) {
1186
-
1187
- this._filter( this.m_filter, ev.type!='change' );
1188
- this._sort( this.m_sort, ev.type!='change' );
1189
-
1183
+ /**
1184
+ * The view follows its store: it is filtered and sorted with what the
1185
+ * store holds now, and again at each of its changes.
1186
+ * Called when the view is made, and to use it again after a close( ).
1187
+ */
1188
+
1189
+ open( ) {
1190
+ if( this.m_unlink ) {
1191
+ return;
1192
+ }
1193
+
1194
+ this.m_unlink = this.m_store.addListener( 'data_change', ( ) => this._storeChange( ) );
1195
+ this._storeChange( );
1196
+ }
1197
+
1198
+ /**
1199
+ * The view stops following its store, until open( ) is called.
1200
+ * A store keeps every view that listens to it: without this, a view
1201
+ * that is not used anymore would still be filtered, sorted and heard at
1202
+ * each change.
1203
+ */
1204
+
1205
+ close( ) {
1206
+ if( this.m_unlink ) {
1207
+ this.m_unlink( );
1208
+ this.m_unlink = null;
1209
+ }
1210
+ }
1211
+
1212
+ private _storeChange( ) {
1213
+
1214
+ // filtered and sorted again without telling: the event that follows says it all.
1215
+ // _filter sorts when the view is sorted; when it is not, it is by id
1216
+ this._filter( this.m_filter, false );
1217
+ if( !this.m_sort ) {
1218
+ this._sort( null, false );
1219
+ }
1220
+
1190
1221
  this.fire( 'view_change', { change_type: 'change' } );
1191
1222
  }
1192
1223
 
@@ -1206,9 +1237,9 @@ export class DataView extends CoreElement<DataViewEventMap>
1206
1237
  this.m_index = this.m_store.createIndex( filter );
1207
1238
  this.m_filter = filter;
1208
1239
 
1209
- // need to sort again:
1240
+ // need to sort again, without telling: the filter is what changed
1210
1241
  if( this.m_sort ) {
1211
- this.sort( this.m_sort );
1242
+ this._sort( this.m_sort, false );
1212
1243
  }
1213
1244
 
1214
1245
  if( notify ) {
@@ -19,7 +19,7 @@ export const COMPONENT = Symbol( "component" );
19
19
  /** @ignore this events must be defined on domNode (do not bubble) */
20
20
  export const unbubbleEvents = {
21
21
  mouseleave: 1, mouseenter: 1, load: 1, unload: 1, scroll: 1, focus: 1, blur: 1, rowexit: 1, beforeunload: 1, stop: 1,
22
- dragdrop: 1, dragenter: 1, dragexit: 1, draggesture: 1, dragover: 1, contextmenu: 1, created: 2, removed: 2, sizechange: 2
22
+ dragdrop: 1, dragenter: 1, dragexit: 1, draggesture: 1, dragover: 1, contextmenu: 1, created: 2, removed: 2, resized: 2
23
23
  };
24
24
 
25
25
  export type DOMEventHandler = ( ev: Event ) => void;
@@ -118,8 +118,12 @@ function observeSize(entries: ResizeObserverEntry[]) {
118
118
 
119
119
  export function dispatchEvent(ev: Event) {
120
120
 
121
- let target = ev.target as Node,
122
- noup = (unbubbleEvents as any)[ev.type] === 2;
121
+ // 2: an event of x4, for its node only.
122
+ // 1: stops at its node when the browser does not bubble it (focus, mouseenter...)
123
+ const flag = (unbubbleEvents as any)[ev.type];
124
+ const noup = flag===2 || ( flag===1 && !ev.bubbles );
125
+
126
+ let target = ev.target as Node;
123
127
 
124
128
  while (target) {
125
129
  const store = event_handlers.get( target );
@@ -127,8 +131,11 @@ export function dispatchEvent(ev: Event) {
127
131
  const callback = store[ev.type];
128
132
  if( callback ) {
129
133
  if( Array.isArray(callback) ) {
130
- callback.some( c => c( ev ) );
131
- }
134
+ // a handler that returns true stops those that follow it on this node.
135
+ // true only: an arrow function returns what its expression does
136
+ // (ev => this.show( ) returns this), and this must stop nothing
137
+ callback.some( c => (c( ev ) as any)===true );
138
+ }
132
139
  else {
133
140
  callback( ev );
134
141
  }
@@ -255,7 +262,17 @@ export interface GlobalDOMEvents {
255
262
  * @param ev The mouse event.
256
263
  */
257
264
  contextmenu?: (ev: MouseEvent) => any;
265
+ /**
266
+ * Fires when the user copies (the keys, or the menu of the browser).
267
+ * @param ev The clipboard event: see setEventClipboard.
268
+ */
269
+ copy?: (ev: ClipboardEvent) => any;
258
270
  cuechange?: (ev: Event) => any;
271
+ /**
272
+ * Fires when the user cuts.
273
+ * @param ev The clipboard event: see setEventClipboard.
274
+ */
275
+ cut?: (ev: ClipboardEvent) => any;
259
276
  /**
260
277
  * Fires when the user double-clicks the object.
261
278
  * @param ev The mouse event.
@@ -391,6 +408,11 @@ export interface GlobalDOMEvents {
391
408
  * @param ev The event.
392
409
  */
393
410
  pause?: (ev: Event) => any;
411
+ /**
412
+ * Fires when the user pastes.
413
+ * @param ev The clipboard event: see getEventClipboard.
414
+ */
415
+ paste?: (ev: ClipboardEvent) => any;
394
416
  /**
395
417
  * Occurs when the play method is requested.
396
418
  * @param ev The event.
@@ -19,13 +19,13 @@ import { Point } from './core_tools';
19
19
 
20
20
  const x_drag_cb = Symbol( 'x-drag-cb' );
21
21
 
22
- interface DropInfo {
22
+ export interface DropInfo {
23
23
  pt: Point;
24
24
  data: DataTransfer;
25
25
  }
26
26
 
27
- type DropCallback = ( command: 'enter' | 'leave' | 'drag' | 'drop', el: Component, infos: DropInfo ) => void;
28
- type FilterCallback = ( el: Component, data: DataTransfer ) => boolean;
27
+ export type DropCallback = ( command: 'enter' | 'leave' | 'drag' | 'drop', el: Component, infos: DropInfo ) => void;
28
+ export type DropFilterCallback = ( el: Component, data: DataTransfer ) => boolean;
29
29
 
30
30
  /**
31
31
  * Manages drag and drop operations within the application.
@@ -78,6 +78,12 @@ class DragManager {
78
78
  el.addDOMEvent('dragend', (ev: DragEvent) => {
79
79
  el.removeClass( 'dragging' );
80
80
  this.dragGhost.remove();
81
+
82
+ // the target that was left is told now, while the source is known.
83
+ // Then the source is forgotten: the next drag may be one of files
84
+ // that come from the desktop, which has none
85
+ this._check( );
86
+ this.dragSource = null;
81
87
  });
82
88
 
83
89
  el.setAttribute('draggable', "true");
@@ -86,7 +92,7 @@ class DragManager {
86
92
  /**
87
93
  * Registers a component as a drop target.
88
94
  * This sets up DOM event listeners for `dragenter`, `dragover`, `dragleave`, and `drop`.
89
- * It uses a `DropCallback` to notify the component about drag events and an optional `FilterCallback`
95
+ * It uses a `DropCallback` to notify the component about drag events and an optional `DropFilterCallback`
90
96
  * to determine if the target should accept the dragged item.
91
97
  *
92
98
  * @param el - The component to make a drop target.
@@ -94,7 +100,7 @@ class DragManager {
94
100
  * @param filterCB - An optional callback to filter which draggable items can be dropped.
95
101
  */
96
102
 
97
- registerDropTarget(el: Component, cb: DropCallback, filterCB?: FilterCallback ) {
103
+ registerDropTarget(el: Component, cb: DropCallback, filterCB?: DropFilterCallback ) {
98
104
 
99
105
  const dragEnter = (ev: DragEvent) => {
100
106
  if( filterCB && !filterCB(this.dragSource,ev.dataTransfer) ) {
@@ -232,7 +232,7 @@ export class CoreElement<E extends EventMap = EventMap> {
232
232
  return;
233
233
  }
234
234
 
235
- // Entries no longer mutate the map in clear(), so plain iteration is safe.
235
+ // clear() of an entry does not touch the map, so plain iteration is safe.
236
236
  for (const entry of this.#timers.values()) {
237
237
  entry.clear();
238
238
  }
@@ -51,7 +51,7 @@ interface Keys {
51
51
  key: string;
52
52
  }
53
53
 
54
- const isMac = typeof navigator!=="undefined" && /mac|iphone|ipad/i.test( navigator.platform ?? "" );
54
+ const isMac = /mac|iphone|ipad/i.test( navigator.platform ?? "" );
55
55
 
56
56
  /**
57
57
  * "Shift+Mod+K" -> shift, ctrl (cmd on a Mac), k
package/src/x4.d.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  **/
16
16
  export * from "./core/component";
17
17
  export * from "./core/core_application";
18
+ export * from "./core/core_clipboard";
18
19
  export * from "./core/core_colors";
19
20
  export * from "./core/core_data";
20
21
  export * from "./core/core_dom";
package/src/x4.ts CHANGED
@@ -16,6 +16,7 @@
16
16
 
17
17
  export * from "./core/component"
18
18
  export * from "./core/core_application"
19
+ export * from "./core/core_clipboard"
19
20
  export * from "./core/core_colors"
20
21
  export * from "./core/core_data"
21
22
  export * from "./core/core_dom"