x4js 2.3.14 → 2.3.16

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
@@ -39,17 +39,18 @@ CoreElement<E> named timers + event surface (no DOM)
39
39
  │ ├── Progress, TickLine, Canvas, Image, Spreadsheet, MonacoEditor
40
40
  │ ├── SvgComponent → Chart
41
41
  │ ├── CSizer → HSizer / VSizer
42
- │ ├── FileDialog, ScrollView, Viewport, Flex, Space
42
+ │ ├── FileDialog, ScrollView, Viewport, DockingView, Flex, Space
43
43
  │ ├── HBox-based: ColorInput, Rating, Switch, Keyboard, TextEdit → AutoComplete, Breadcrumbs
44
44
  │ └── VBox-based: Calendar, ColorPicker, FileDrop, Panel, PropertyGrid, TextArea
45
45
  └── Application<E> singleton, no DOM of its own
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, Shortcuts, Stylesheet, ComputedStyle,
53
54
  SvgBuilder, X4PDFBuilder, x4_react, CMover, UnsafeHtml (extends String)
54
55
  ```
55
56
 
@@ -239,6 +240,7 @@ setCapture(pointerId: number): void; releaseCapture(pointerId: number): void
239
240
  animate(keyframes: Keyframe[], duration: number): void
240
241
  onGlobalEvent(cb: (ev: EvMessage) => void): void // auto-removed on DOM removal
241
242
  queryInterface<T>(name: string): T // "form-element" → IFormElement; "tab-handler" → ITabHandler
243
+ addShortcut(keys: string | string[], callback: (ev: KeyboardEvent) => void, options?: ShortcutOptions): this // see Shortcuts
242
244
  ```
243
245
 
244
246
  ### Exported helpers
@@ -284,6 +286,7 @@ setEnv(name: string, value: any): void
284
286
  getEnv(name: string, def_value?: any): any
285
287
  static fireGlobal(msg: string, params?: any): void
286
288
  focusNext(next: boolean): boolean
289
+ addShortcut(keys: string | string[], callback: (ev: KeyboardEvent) => void, options?: ShortcutOptions): void // see Shortcuts
287
290
  setupSocketMessaging(path?: string, looseCallback?: () => void): void
288
291
  getStorage(name: string): string
289
292
  setStorage(name: string, value: string|number): void
@@ -386,6 +389,79 @@ class MasonryBox extends Box {
386
389
 
387
390
  ---
388
391
 
392
+ ## `DockingView` — panels the user arranges by dragging their title
393
+
394
+ Not a `Box`. Two kinds of panels, which never mix: **tools**, docked in a band on the left or on the right
395
+ (one above the other, side by side, or as tabs) and which can float; **contents**, what is worked on
396
+ (as the editors of an IDE), which share the room between the bands, split or as tabs.
397
+
398
+ ```ts
399
+ interface DockPanel {
400
+ id: string
401
+ title?: string // shown in its tab; the id when missing
402
+ content: Component
403
+ kind?: 'tool' | 'content' // default 'tool'
404
+ closable?: boolean // for this panel, instead of the view's
405
+ floating?: boolean // idem; a content never floats
406
+ }
407
+ interface DockingViewProps extends ComponentProps {
408
+ panels: DockPanel[]
409
+ layout: DockLayout // where the panels are at first
410
+ floating?: boolean // tools can float above the rest
411
+ closable?: boolean // tools have a button that hides them
412
+ persist?: string // a name: the layout is remembered under it (application storage)
413
+ layoutChange?: EventCallback<ComponentEvent>
414
+ }
415
+ class DockingView extends Component {
416
+ getState(): DockLayout // plain data, to save
417
+ setState(layout: unknown): void // a saved layout; what it lacks comes from props.layout
418
+ resetLayout(): void // back to props.layout
419
+ showPanel(id: string, show?: boolean): void
420
+ isPanelVisible(id: string): boolean
421
+ }
422
+
423
+ interface DockLayout {
424
+ left: DockBand; right: DockBand // the tools
425
+ content: DockNode // the contents; null when there is none
426
+ floats: DockFloat[] // floating tools: { panel, x, y, w, h }
427
+ hidden: string[] // ids of the closed panels
428
+ }
429
+ interface DockBand { size: number; root: DockNode } // size: width in px; root: null when empty
430
+ type DockNode = DockStack | DockSplit
431
+ interface DockStack { type: 'stack'; size: number; panels: string[]; active: string } // one tab per panel
432
+ interface DockSplit { type: 'split'; dir: 'row' | 'column'; size: number; children: DockNode[] }
433
+ // size of a node: its share of its split (0 = an equal share)
434
+ ```
435
+
436
+ ```ts
437
+ const view = new DockingView({
438
+ flex: true,
439
+ floating: true,
440
+ closable: true,
441
+ persist: 'myapp.layout',
442
+ panels: [
443
+ { id: 'tools', title: 'Tools', content: tools },
444
+ { id: 'props', title: 'Properties', content: props },
445
+ { id: 'page', kind: 'content', content: editor },
446
+ ],
447
+ layout: {
448
+ left: { size: 180, root: { type: 'stack', size: 0, panels: ['tools'], active: 'tools' } },
449
+ right: { size: 260, root: { type: 'stack', size: 0, panels: ['props'], active: 'props' } },
450
+ content: { type: 'stack', size: 0, panels: ['page'], active: 'page' },
451
+ floats: [],
452
+ hidden: [],
453
+ },
454
+ })
455
+ ```
456
+
457
+ - A closed panel is hidden, not removed: `showPanel(id)` brings it back where it was. Offer it in a menu.
458
+ - Tools only go in `left`, `right` or `floats`; contents only in `content`. A panel missing from the layout is added where its kind goes.
459
+ - A content alone has no title bar; with several contents, each stack has tabs.
460
+ - What a panel shows is built once (`content`) and moved as the layout changes: never rebuild it.
461
+ - CSS variables: `--dock-line`, `--dock-accent`, `--dock-title-background`, `--dock-title-background-hover`, `--dock-title-color`, `--dock-title-color-active`, `--dock-float-background`, `--dock-float-shadow`.
462
+
463
+ ---
464
+
389
465
  ## Display components
390
466
 
391
467
  ### `Label`
@@ -1099,6 +1175,46 @@ parseRoute(str: string|RegExp, loose?: boolean): Segment
1099
1175
 
1100
1176
  ---
1101
1177
 
1178
+ ## Shortcuts (`core_shortcuts`)
1179
+
1180
+ Keyboard shortcuts. A sequence is written as it is read: `"Mod+C"`, `"Shift+Mod+Z"`, `"Delete"`, `"Alt+ArrowLeft"`.
1181
+ Modifiers: `Shift`, `Ctrl`, `Cmd`, `Alt`, and `Mod` = the key of the commands (Ctrl on Windows and Linux, Cmd on a Mac). Prefer `Mod`.
1182
+
1183
+ ```ts
1184
+ interface ShortcutOptions { editable?: boolean } // true: also while the user types in a field
1185
+
1186
+ component.addShortcut(keys, callback, options?) // a view: while the focus is in the component
1187
+ Application.instance().addShortcut(keys, callback, options?) // the whole application, wherever the focus is
1188
+
1189
+ shortcutText(keys: string): string // for a tooltip or a menu: "Ctrl+Z", "⌘Z" on a Mac
1190
+
1191
+ class Shortcuts { // the list itself; rarely used directly
1192
+ add(keys: string | string[], callback: (ev: KeyboardEvent) => void, options?: ShortcutOptions): void
1193
+ clear(): void
1194
+ handle(ev: KeyboardEvent): boolean // true when the key was a shortcut
1195
+ }
1196
+ ```
1197
+
1198
+ ```ts
1199
+ class MyView extends VBox {
1200
+ constructor(props: BoxProps) {
1201
+ super(props)
1202
+ this.addShortcut('Mod+C', () => this.copy())
1203
+ this.addShortcut(['Mod+Y', 'Shift+Mod+Z'], () => this.redo())
1204
+ this.setContent(new Button({ label: 'Undo', tooltip: shortcutText('Mod+Z'), click: () => this.undo() }))
1205
+ }
1206
+ }
1207
+ ```
1208
+
1209
+ - A component shortcut also works while the focus is nowhere, if the component is the last one with shortcuts that had the focus: of two views shown together, the one the user works in answers.
1210
+ - A key typed in an `input`, a `textarea` or a `select` is not a shortcut, unless `editable` is set.
1211
+ - A key that what has the focus already used (it called `preventDefault` or `stopPropagation`) is not a shortcut.
1212
+ - A shortcut always takes its key (`preventDefault`). For a key that must sometimes be left alone, use a `keydown` handler instead.
1213
+ - An application shortcut also works while a dialog is open.
1214
+ - Do not write a `keydown` handler with a `switch` on `ev.ctrlKey` for commands: use `addShortcut`.
1215
+
1216
+ ---
1217
+
1102
1218
  ## i18n (`core_i18n`)
1103
1219
 
1104
1220
  Two built-in languages (`fr` default, and `en`).
@@ -1277,17 +1393,36 @@ initTooltips(): void // initialize the global
1277
1393
 
1278
1394
  ---
1279
1395
 
1280
- ## State (`core_state`) — Experimental
1396
+ ## State (`core_state`)
1281
1397
 
1282
1398
  ```ts
1283
- class StateManager {
1284
- constructor(initialState)
1285
- getState(path: string, defaultValue?): any
1286
- setState(path: string, value, context?): void
1399
+ makeState<T>(initialState: T): StateProxy<T> // the data itself + on / off / once / watch
1400
+
1401
+ const state = makeState({ count: 0, user: { name: '' }, items: [1, 2, 3] })
1402
+ state.on('change', e => ...) // EvStateChange { path, value }
1403
+ state.watch('user', e => ...) // 'user', anything below it, or a parent replaced; returns { off() }
1404
+ state.count++ // path "count"
1405
+ state.user.name = 'x' // path "user.name"
1406
+ state.items.push(4) // path "items[3]"
1407
+
1408
+ class StateManager<T> extends EventSource<StateEvents> {
1409
+ constructor(initialState: T)
1410
+ proxify(): StateProxy<T>
1411
+ on(name, listener): { off(): void }
1412
+ off(name, listener): void
1413
+ once(name, listener): { off(): void }
1414
+ watch(path: string, cb: (ev: EvStateChange) => void): { off(): void }
1287
1415
  }
1288
- // NOTE: marked @Experimental in the sources.
1416
+
1417
+ // Component (protected): a state kept in localStorage under "x4@persist:<name>",
1418
+ // saved 500 ms after a change
1419
+ loadPState(name: string, defaults: Record<string, any>): any
1289
1420
  ```
1290
1421
 
1422
+ - Plain objects and arrays are proxied when read; Date, Map, Set and class instances are not.
1423
+ - Setting a property to the value it already has fires nothing.
1424
+ - Reading a property that does not exist logs an error and returns `undefined`.
1425
+
1291
1426
  ---
1292
1427
 
1293
1428
  ## Common patterns
@@ -1487,7 +1622,6 @@ Optional keys: `external`, `define`, `esbuild`, `dev` (`host`, `port`, `https`,
1487
1622
  - The default language is `fr`. Call `selectLanguage('en')` to switch to English.
1488
1623
  - `Gridview` is backed by a `DataStore`; `Spreadsheet` is backed by a `Store` (different, cell-based).
1489
1624
  - `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
1625
  - `MonacoEditor` requires `MonacoEditor.start()` (async) before use; register extra typings with `MonacoEditor.addTypelib(name, code)`.
1492
1626
  - `x4_react.create_element` is the JSX pragma;
1493
1627
  - `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.16",
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 => {
@@ -12,6 +12,7 @@ export * from "./colorinput/colorinput"
12
12
  export * from "./colorpicker/colorpicker"
13
13
  export * from "./combobox/combobox"
14
14
  export * from "./dialog/dialog"
15
+ export * from "./dockingview/dockingview"
15
16
  export * from "./filedrop/filedrop"
16
17
  export * from "./form/form"
17
18
  export * from "./gauge/gauge"
@@ -0,0 +1,279 @@
1
+ /**
2
+ * ___ ___ __
3
+ * \ \/ / / _
4
+ * \ / /_| |_
5
+ * / \____ _|
6
+ * /__/\__\ |_|
7
+ *
8
+ * @file dockingview.module.scss
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
+ .x4dockingview {
18
+ --dock-line: var( --border, #cfdae0 ); // between two panels, under a title
19
+ --dock-title-background: var( --background-secondary, #f3f7f9 );
20
+ --dock-title-background-hover: color-mix( in srgb, var( --dock-accent ) 25%, transparent );
21
+ --dock-title-color: var( --text-ternary, #607784 );
22
+ --dock-title-color-active: var( --text-primary, #002a40 );
23
+ --dock-accent: var( --accent-background, #126e9e ); // the tab in front, the sizer under the pointer, the preview
24
+ --dock-float-background: var( --background-primary, white );
25
+ --dock-float-shadow: 0 6px 24px rgba( 0, 0, 0, 0.35 );
26
+
27
+ position: relative;
28
+ display: flex;
29
+ min-width: 0;
30
+ min-height: 0;
31
+ overflow: hidden;
32
+
33
+ // it has no size of its own: it takes what its container gives it,
34
+ // even in an HBox, which centers its children
35
+ align-self: stretch;
36
+
37
+ // The boxes of the layout: the band of the left, the room of the
38
+ // contents, the band of the right. The floating tools are placed from
39
+ // its top-left corner, which is the one of the box.
40
+ > .dock-frame {
41
+ position: relative;
42
+ display: flex;
43
+ flex: 1 1 0;
44
+ min-width: 0;
45
+ min-height: 0;
46
+ }
47
+
48
+ // The tools of one side. Its width is given by the layout; it only
49
+ // gets narrower when the box has no room left.
50
+ .dock-band {
51
+ display: flex;
52
+ flex: 0 1 auto;
53
+ min-width: 48px;
54
+ min-height: 0;
55
+ overflow: hidden;
56
+
57
+ > .dock-split,
58
+ > .dock-stack {
59
+ flex: 1 1 0;
60
+ }
61
+ }
62
+
63
+ // the room of the contents: what the bands leave
64
+ .dock-area {
65
+ display: flex;
66
+ flex: 1 1 0;
67
+ min-width: 0;
68
+ min-height: 0;
69
+ overflow: hidden;
70
+
71
+ > .dock-split,
72
+ > .dock-stack {
73
+ flex: 1 1 0;
74
+ }
75
+ }
76
+
77
+ .dock-split {
78
+ display: flex;
79
+ min-width: 0;
80
+ min-height: 0;
81
+ overflow: hidden;
82
+
83
+ &.column {
84
+ flex-direction: column;
85
+ }
86
+
87
+ // the smallest size of a child (MIN_SIZE in dockingview.ts)
88
+ &.row > .dock-split,
89
+ &.row > .dock-stack {
90
+ min-width: 48px;
91
+ }
92
+
93
+ &.column > .dock-split,
94
+ &.column > .dock-stack {
95
+ min-height: 48px;
96
+ }
97
+ }
98
+
99
+ // One line between two children. It is caught from a little further
100
+ // than what is drawn.
101
+ .dock-sizer {
102
+ position: relative;
103
+ z-index: 1;
104
+ flex: none;
105
+ align-self: stretch;
106
+ min-width: 1px;
107
+ min-height: 1px;
108
+ background: var( --dock-line );
109
+ touch-action: none;
110
+
111
+ &::after {
112
+ content: "";
113
+ position: absolute;
114
+ }
115
+
116
+ &:hover,
117
+ &:active {
118
+ background: var( --dock-accent );
119
+ }
120
+ }
121
+
122
+ // between two boxes that are side by side
123
+ .dock-sizer.row {
124
+ cursor: ew-resize;
125
+
126
+ &::after {
127
+ inset: 0 -3px;
128
+ }
129
+ }
130
+
131
+ // between two boxes that are one above the other
132
+ .dock-sizer.column {
133
+ cursor: ns-resize;
134
+
135
+ &::after {
136
+ inset: -3px 0;
137
+ }
138
+ }
139
+
140
+ .dock-stack {
141
+ display: flex;
142
+ flex-direction: column;
143
+ min-width: 0;
144
+ min-height: 0;
145
+ overflow: hidden;
146
+
147
+ // what a panel shows keeps its z-index for itself: it stays under
148
+ // the floating panels
149
+ isolation: isolate;
150
+ }
151
+
152
+ // the titles of a stack, which are also what it is dragged by
153
+ .dock-titles {
154
+ display: flex;
155
+ flex: none;
156
+ overflow: hidden;
157
+ background: var( --dock-title-background );
158
+ border-bottom: 1px solid var( --dock-line );
159
+ cursor: grab;
160
+ user-select: none;
161
+ touch-action: none;
162
+ }
163
+
164
+ .dock-title {
165
+ display: flex;
166
+ flex: none;
167
+ align-items: center;
168
+ gap: 6px;
169
+ padding: 6px 8px;
170
+ font-weight: 600;
171
+ white-space: nowrap;
172
+ color: var( --dock-title-color );
173
+
174
+ // under the pointer: it can be taken
175
+ &:hover {
176
+ color: var( --dock-title-color-active );
177
+ background: var( --dock-title-background-hover );
178
+ }
179
+
180
+ // The only title of a stack takes the whole bar: its button is at
181
+ // the end of it. Among several tabs, it follows the text of its tab.
182
+ &:only-child {
183
+ flex: 1 1 0;
184
+ min-width: 0;
185
+
186
+ > span {
187
+ flex: 1 1 0;
188
+ min-width: 0;
189
+ overflow: hidden;
190
+ text-overflow: ellipsis;
191
+ }
192
+ }
193
+
194
+ // several tabs: the one in front stands out
195
+ &.active:not( :only-child ) {
196
+ color: var( --dock-title-color-active );
197
+ box-shadow: inset 0 -2px 0 var( --dock-accent );
198
+ }
199
+ }
200
+
201
+ .dock-close {
202
+ width: 16px;
203
+ height: 16px;
204
+ line-height: 16px;
205
+ border-radius: 3px;
206
+ text-align: center;
207
+ font-weight: 400;
208
+ cursor: pointer;
209
+ opacity: 0.6;
210
+
211
+ &:hover {
212
+ opacity: 1;
213
+ background: var( --dock-line );
214
+ }
215
+ }
216
+
217
+ .dock-body {
218
+ display: flex;
219
+ flex: 1 1 0;
220
+ min-width: 0;
221
+ min-height: 0;
222
+ overflow: hidden;
223
+ }
224
+
225
+ // what a panel shows fills its page
226
+ .dock-page {
227
+ display: flex;
228
+ flex-direction: column;
229
+ flex: 1 1 0;
230
+ min-width: 0;
231
+ min-height: 0;
232
+ overflow: hidden;
233
+
234
+ > * {
235
+ flex: 1 1 0;
236
+ min-width: 0;
237
+ min-height: 0;
238
+ }
239
+ }
240
+
241
+ .dock-float {
242
+ position: absolute;
243
+ z-index: 20;
244
+ display: flex;
245
+ flex-direction: column;
246
+ min-width: 120px;
247
+ min-height: 80px;
248
+ overflow: hidden;
249
+ isolation: isolate;
250
+
251
+ background: var( --dock-float-background );
252
+ border: 1px solid var( --dock-line );
253
+ border-radius: 4px;
254
+ box-shadow: var( --dock-float-shadow );
255
+ }
256
+
257
+ // where the panel that is dragged will go
258
+ .dock-preview {
259
+ position: absolute;
260
+ z-index: 30;
261
+ box-sizing: border-box;
262
+ pointer-events: none;
263
+
264
+ background: color-mix( in srgb, var( --dock-accent ) 30%, transparent );
265
+ border: 1px solid var( --dock-accent );
266
+ border-radius: 3px;
267
+ }
268
+
269
+ // It holds the pointer during a drag: its cursor is the one that shows.
270
+ // It must be in the page to do so, but takes no room.
271
+ .dock-grip {
272
+ position: absolute;
273
+ left: 0;
274
+ top: 0;
275
+ width: 0;
276
+ height: 0;
277
+ cursor: grabbing;
278
+ }
279
+ }