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
@@ -91,11 +91,8 @@ function _getMetas( obj: object, create = true ) : MetaInfos {
91
91
  mfld = Object.prototype.hasOwnProperty.call(ctor,metaFields) ? ctor[metaFields] : undefined;
92
92
  }
93
93
 
94
+ // a class without decorators of its own comes here too: its metas are made from its parent's
94
95
  if( mfld===undefined ) {
95
- if( !create && ctor!=DataModel ) {
96
- console.assert( mfld!==undefined );
97
- }
98
-
99
96
  // construct our metas
100
97
  mfld = new MetaInfos( ctor.name );
101
98
 
@@ -105,8 +102,6 @@ function _getMetas( obj: object, create = true ) : MetaInfos {
105
102
  if( pctor!=DataModel ) {
106
103
  let pmetas = pctor[metaFields];
107
104
  mfld.fields = [...pmetas.fields, ...mfld.fields ]
108
-
109
- console.assert( mfld.id===undefined, 'cannot define mutiple record id' );
110
105
  if( !mfld.id ) {
111
106
  mfld.id = pmetas.id;
112
107
  }
@@ -143,6 +138,7 @@ export namespace data {
143
138
  required: true,
144
139
  });
145
140
 
141
+ console.assert( !metas.id, 'cannot define multiple record id' ); // the parent class has one already
146
142
  metas.id = fldName;
147
143
  }
148
144
  }
@@ -371,7 +367,7 @@ export class DataModel<T = any> {
371
367
  });
372
368
 
373
369
  if( id!==undefined ) {
374
- rec[fields[0].name] = id;
370
+ rec[_getMetas( this, false ).id] = id; // the field of the id, as getID reads it
375
371
  }
376
372
  else {
377
373
  console.assert( this.getID(rec)!==undefined ); // store do not have ID field
@@ -517,6 +513,10 @@ export interface DataProxyProps {
517
513
  solver?: DataSolver;
518
514
  }
519
515
 
516
+ /**
517
+ * @deprecated the loading from an url is to be reviewed
518
+ */
519
+
520
520
  export class DataProxy extends CoreElement<DataEventMap> {
521
521
 
522
522
  protected m_props: DataProxyProps;
@@ -561,8 +561,11 @@ export class DataProxy extends CoreElement<DataEventMap> {
561
561
  interface DataStoreProps {
562
562
  model: DataModel;
563
563
  data?: any[];
564
+ /** @deprecated the loading from an url is to be reviewed: load the data, then setData */
564
565
  url?: string;
566
+ /** @deprecated see url */
565
567
  autoload?: false;
568
+ /** @deprecated see url */
566
569
  solver?: DataSolver;
567
570
  }
568
571
 
@@ -615,14 +618,15 @@ export class DataStore<T = any> extends EventSource<DataStoreEventMap> {
615
618
  }
616
619
 
617
620
  /**
618
- *
619
- * @param records
621
+ * @deprecated the loading from an url is to be reviewed: load the data, then setData.
622
+ * Only for a store created with url and no data; an HTTP error is not reported
620
623
  */
621
624
 
622
625
  async load( url?: string ) {
623
626
  return this.m_proxy.load( url );
624
627
  }
625
628
 
629
+ /** @deprecated see load */
626
630
  async reload( ) {
627
631
  return this.m_proxy.load( );
628
632
  }
@@ -813,8 +817,13 @@ export class DataStore<T = any> extends EventSource<DataStoreEventMap> {
813
817
  return this.m_records[index] ?? null;
814
818
  }
815
819
 
820
+ /**
821
+ * gives the records to other: this store is emptied, they do not share them
822
+ */
823
+
816
824
  public moveTo( other: DataStore ) {
817
825
  other.setRawData( this.m_records );
826
+ this.setRawData( [] );
818
827
  }
819
828
 
820
829
  /**
@@ -871,32 +880,32 @@ export class DataStore<T = any> extends EventSource<DataStoreEventMap> {
871
880
  return new Uint32Array(0);
872
881
  }
873
882
 
874
- let filterValue = filter.value;
883
+ let filterValue: any = filter.value;
875
884
  if( isString(filterValue) && !filter.caseSensitive ) {
876
885
  filterValue = filterValue.toUpperCase( );
877
886
  }
878
-
879
- const _lt = ( recval: string ) : boolean => {
887
+
888
+ const _lt = ( recval: any ) : boolean => {
880
889
  return recval < filterValue;
881
890
  }
882
891
 
883
- const _le = ( recval: string ) : boolean => {
892
+ const _le = ( recval: any ) : boolean => {
884
893
  return recval <= filterValue;
885
894
  }
886
895
 
887
- const _eq = ( recval: string ) : boolean => {
896
+ const _eq = ( recval: any ) : boolean => {
888
897
  return recval == filterValue;
889
898
  }
890
899
 
891
- const _neq = ( recval: string ) : boolean => {
900
+ const _neq = ( recval: any ) : boolean => {
892
901
  return recval != filterValue;
893
902
  }
894
903
 
895
- const _ge = ( recval: string ) : boolean => {
904
+ const _ge = ( recval: any ) : boolean => {
896
905
  return recval >= filterValue;
897
906
  }
898
907
 
899
- const _gt = ( recval: string ) : boolean => {
908
+ const _gt = ( recval: any ) : boolean => {
900
909
  return recval > filterValue;
901
910
  }
902
911
 
@@ -905,9 +914,10 @@ export class DataStore<T = any> extends EventSource<DataStoreEventMap> {
905
914
  return filterRe.test( recval );
906
915
  }
907
916
 
908
- let filterFn: ( rec: string ) => boolean; // filter fn
917
+ let filterFn: ( rec: any ) => boolean; // filter fn
909
918
  let filterRe: RegExp; // if fielter is regexp
910
919
  if( filterValue instanceof RegExp ) {
920
+ // tested on the field as it is: the regexp says if the case counts (flag i)
911
921
  filterRe = filterValue;
912
922
  filterFn = _re;
913
923
  }
@@ -921,7 +931,14 @@ export class DataStore<T = any> extends EventSource<DataStoreEventMap> {
921
931
  case '<>': { filterFn = _neq; break; }
922
932
  }
923
933
  }
924
-
934
+
935
+ // int, float, date: compared as numbers, a date by its time
936
+ const type = this.m_fields[filterFld].type;
937
+ const numeric = !filterRe && (type==='int' || type==='float' || type==='date');
938
+ if( numeric ) {
939
+ filterValue = type==='date' ? new Date( filter.value as any ).getTime( ) : Number( filter.value );
940
+ }
941
+
925
942
  // scan all records and append only interesting ones
926
943
  const m = this.m_model;
927
944
 
@@ -933,12 +950,16 @@ export class DataStore<T = any> extends EventSource<DataStoreEventMap> {
933
950
  }
934
951
 
935
952
  let field = m.getRaw( filterFld, rec );
936
- if( field===null || field===undefined ) {
953
+ if( numeric ) {
954
+ // +: a Date gives its time, without a new object. A null field: NaN, every comparison is false but <>
955
+ field = field===null || field===undefined ? NaN : +field;
956
+ }
957
+ else if( field===null || field===undefined ) {
937
958
  field = '';
938
959
  }
939
960
  else {
940
961
  field = ''+field;
941
- if( !filter.caseSensitive ) {
962
+ if( !filter.caseSensitive && !filterRe ) {
942
963
  field = field.toUpperCase( );
943
964
  }
944
965
  }
@@ -960,14 +981,17 @@ export class DataStore<T = any> extends EventSource<DataStoreEventMap> {
960
981
  fidx: number,
961
982
  asc: boolean,
962
983
  cb: SortCallback,
984
+ numeric: boolean,
963
985
  }
964
986
 
965
987
  let bads = 0; // unknown fields
966
988
  let fidxs: sort_info[] = []; // fields indexes
967
989
 
968
- // if no fields are given, reset sort by id
990
+ // if no fields are given, reset sort by id: the field marked by data.id( ),
991
+ // which is not always the first one, and the order in which indexOfId searches
969
992
  if ( sort===null ) {
970
- fidxs.push( { fidx: 0, asc: true, cb: null } );
993
+ const id = _getMetas( this.m_model, false ).id;
994
+ fidxs.push( { fidx: this.m_model.getFieldIndex( id ), asc: true, cb: null, numeric: false } );
971
995
  }
972
996
  else {
973
997
  fidxs = sort.map( (si) => {
@@ -978,7 +1002,7 @@ export class DataStore<T = any> extends EventSource<DataStoreEventMap> {
978
1002
  bads++;
979
1003
  }
980
1004
 
981
- return { fidx: fi, asc: si.ascending, cb: si.callback };
1005
+ return { fidx: fi, asc: si.ascending, cb: si.callback, numeric: si.numeric };
982
1006
  });
983
1007
  }
984
1008
 
@@ -994,6 +1018,7 @@ export class DataStore<T = any> extends EventSource<DataStoreEventMap> {
994
1018
 
995
1019
  const field = fidxs[0].fidx;
996
1020
  const cb = fidxs[0].cb;
1021
+ const numeric = fidxs[0].numeric;
997
1022
 
998
1023
  index.sort( ( ia, ib ) => {
999
1024
 
@@ -1004,6 +1029,10 @@ export class DataStore<T = any> extends EventSource<DataStoreEventMap> {
1004
1029
  return cb( va, vb );
1005
1030
  }
1006
1031
 
1032
+ if( numeric ) {
1033
+ return Number( va ) - Number( vb );
1034
+ }
1035
+
1007
1036
  if (va > vb) { return 1; }
1008
1037
  if (va < vb) { return -1; }
1009
1038
  return 0;
@@ -1024,8 +1053,17 @@ export class DataStore<T = any> extends EventSource<DataStoreEventMap> {
1024
1053
  let va = m.getRaw( fidx, this.getByIndex(ia) ) ?? '';
1025
1054
  let vb = m.getRaw( fidx, this.getByIndex(ib) ) ?? '';
1026
1055
 
1056
+ // a field that finds them equal gives the hand to the next one
1027
1057
  if( fi.cb ) {
1028
- return fi.cb( va, vb );
1058
+ const r = fi.cb( va, vb );
1059
+ if( r ) { return r*mul; }
1060
+ continue;
1061
+ }
1062
+
1063
+ if( fi.numeric ) {
1064
+ const r = Number( va ) - Number( vb );
1065
+ if( r ) { return r*mul; } // NaN, not a number: equal
1066
+ continue;
1029
1067
  }
1030
1068
 
1031
1069
  if (va > vb) { return mul; }
@@ -1121,7 +1159,7 @@ export type FilterFunc = ( rec: DataRecord ) => boolean;
1121
1159
  export interface FilterInfo {
1122
1160
  op: '<' | '<=' | '=' | '>=' | '>' | '<>' | 'empty-result' | FilterFunc, // emptydb mean return an empty result always
1123
1161
  field?: string;
1124
- value?: string | RegExp; // if regexp then operator is =
1162
+ value?: string | number | RegExp; // if regexp then operator is =
1125
1163
  caseSensitive?: boolean;
1126
1164
  }
1127
1165
 
@@ -1152,41 +1190,72 @@ export class DataView extends CoreElement<DataViewEventMap>
1152
1190
 
1153
1191
  protected m_props: DataViewProps;
1154
1192
 
1193
+ private m_unlink: ( ) => void; // stops listening to the store
1194
+
1155
1195
  constructor( props: DataViewProps ) {
1156
1196
  super( );
1157
1197
 
1158
1198
  this.m_props = props;
1159
1199
  this.m_store = props.store;
1160
1200
  this.m_index = null;
1161
- this.m_filter = null;
1162
- this.m_sort = null;
1163
1201
  this.m_model = this.m_store.getModel();
1202
+ this.m_filter = props.filter;
1164
1203
 
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
- }
1204
+ if( !props.order ) {
1205
+ this.m_sort = null;
1206
+ }
1207
+ else if( isString(props.order) ) {
1208
+ this.m_sort = [ { field: props.order, ascending: true } ];
1209
+ }
1210
+ else if( isArray(props.order) ) {
1211
+ this.m_sort = props.order;
1177
1212
  }
1178
1213
  else {
1179
- this.sort( null );
1214
+ this.m_sort = [props.order];
1180
1215
  }
1181
1216
 
1182
- this.m_store.addListener( 'data_change', ( e ) => this._storeChange(e) );
1217
+ // listens to the store, then filters and sorts
1218
+ this.open( );
1183
1219
  }
1184
1220
 
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
-
1221
+ /**
1222
+ * The view follows its store: it is filtered and sorted with what the
1223
+ * store holds now, and again at each of its changes.
1224
+ * Called when the view is made, and to use it again after a close( ).
1225
+ */
1226
+
1227
+ open( ) {
1228
+ if( this.m_unlink ) {
1229
+ return;
1230
+ }
1231
+
1232
+ this.m_unlink = this.m_store.addListener( 'data_change', ( ) => this._storeChange( ) );
1233
+ this._storeChange( );
1234
+ }
1235
+
1236
+ /**
1237
+ * The view stops following its store, until open( ) is called.
1238
+ * A store keeps every view that listens to it: without this, a view
1239
+ * that is not used anymore would still be filtered, sorted and heard at
1240
+ * each change.
1241
+ */
1242
+
1243
+ close( ) {
1244
+ if( this.m_unlink ) {
1245
+ this.m_unlink( );
1246
+ this.m_unlink = null;
1247
+ }
1248
+ }
1249
+
1250
+ private _storeChange( ) {
1251
+
1252
+ // filtered and sorted again without telling: the event that follows says it all.
1253
+ // _filter sorts when the view is sorted; when it is not, it is by id
1254
+ this._filter( this.m_filter, false );
1255
+ if( !this.m_sort ) {
1256
+ this._sort( null, false );
1257
+ }
1258
+
1190
1259
  this.fire( 'view_change', { change_type: 'change' } );
1191
1260
  }
1192
1261
 
@@ -1206,9 +1275,9 @@ export class DataView extends CoreElement<DataViewEventMap>
1206
1275
  this.m_index = this.m_store.createIndex( filter );
1207
1276
  this.m_filter = filter;
1208
1277
 
1209
- // need to sort again:
1278
+ // need to sort again, without telling: the filter is what changed
1210
1279
  if( this.m_sort ) {
1211
- this.sort( this.m_sort );
1280
+ this._sort( this.m_sort, false );
1212
1281
  }
1213
1282
 
1214
1283
  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;
@@ -38,12 +38,14 @@ const observeMutation = (mutations: MutationRecord[], observer: MutationObserver
38
38
  // moves are handled this way
39
39
  const added = new Set<Node>( );
40
40
  const removed = new Set<Node>( );
41
+ const moved = new Set<Node>( ); // out of their place and back in the document in the same task
41
42
 
42
43
  for( const mutation of mutations ) {
43
44
  if( mutation.type == "childList" ) {
44
45
  mutation.addedNodes.forEach( n => {
45
46
  if( removed.has( n ) ) {
46
- removed.delete( n );
47
+ removed.delete( n );
48
+ moved.add( n );
47
49
  }
48
50
  else {
49
51
  added.add( n );
@@ -70,6 +72,12 @@ const observeMutation = (mutations: MutationRecord[], observer: MutationObserver
70
72
 
71
73
  const notify = ( node: Node, create: boolean ) => {
72
74
 
75
+ // a node that was only moved was created before, as all that it holds,
76
+ // even when it is moved into a node that is new
77
+ if( create && moved.has( node ) ) {
78
+ return;
79
+ }
80
+
73
81
  if( create ) {
74
82
  sendEvent( node, "created" );
75
83
  }
@@ -118,8 +126,12 @@ function observeSize(entries: ResizeObserverEntry[]) {
118
126
 
119
127
  export function dispatchEvent(ev: Event) {
120
128
 
121
- let target = ev.target as Node,
122
- noup = (unbubbleEvents as any)[ev.type] === 2;
129
+ // 2: an event of x4, for its node only.
130
+ // 1: stops at its node when the browser does not bubble it (focus, mouseenter...)
131
+ const flag = (unbubbleEvents as any)[ev.type];
132
+ const noup = flag===2 || ( flag===1 && !ev.bubbles );
133
+
134
+ let target = ev.target as Node;
123
135
 
124
136
  while (target) {
125
137
  const store = event_handlers.get( target );
@@ -127,8 +139,11 @@ export function dispatchEvent(ev: Event) {
127
139
  const callback = store[ev.type];
128
140
  if( callback ) {
129
141
  if( Array.isArray(callback) ) {
130
- callback.some( c => c( ev ) );
131
- }
142
+ // a handler that returns true stops those that follow it on this node.
143
+ // true only: an arrow function returns what its expression does
144
+ // (ev => this.show( ) returns this), and this must stop nothing
145
+ callback.some( c => (c( ev ) as any)===true );
146
+ }
132
147
  else {
133
148
  callback( ev );
134
149
  }
@@ -255,7 +270,17 @@ export interface GlobalDOMEvents {
255
270
  * @param ev The mouse event.
256
271
  */
257
272
  contextmenu?: (ev: MouseEvent) => any;
273
+ /**
274
+ * Fires when the user copies (the keys, or the menu of the browser).
275
+ * @param ev The clipboard event: see setEventClipboard.
276
+ */
277
+ copy?: (ev: ClipboardEvent) => any;
258
278
  cuechange?: (ev: Event) => any;
279
+ /**
280
+ * Fires when the user cuts.
281
+ * @param ev The clipboard event: see setEventClipboard.
282
+ */
283
+ cut?: (ev: ClipboardEvent) => any;
259
284
  /**
260
285
  * Fires when the user double-clicks the object.
261
286
  * @param ev The mouse event.
@@ -391,6 +416,11 @@ export interface GlobalDOMEvents {
391
416
  * @param ev The event.
392
417
  */
393
418
  pause?: (ev: Event) => any;
419
+ /**
420
+ * Fires when the user pastes.
421
+ * @param ev The clipboard event: see getEventClipboard.
422
+ */
423
+ paste?: (ev: ClipboardEvent) => any;
394
424
  /**
395
425
  * Occurs when the play method is requested.
396
426
  * @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
  }