x4js 2.2.32 → 2.2.34
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/README.md +93 -13
- package/package.json +1 -2
- package/src/components/monaco/monaco.ts +41 -22
- package/src/core/component.ts +37 -1
- package/src/core/core_application.ts +2 -2
- package/src/core/core_state.ts +205 -42
- package/src/core/core_tools.ts +128 -3
package/README.md
CHANGED
|
@@ -1,14 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
This is the x4 framework repository.
|
|
5
|
-
|
|
6
|
-
X4 is a full typescript framework to develop Web or Desktop applications.
|
|
7
|
-
Included more than 50 controls like grids, charts, etc.
|
|
8
|
-
|
|
9
|
-
Everything needed to push the limits of your creativity.
|
|
10
|
-
|
|
11
|
-
If these components are not enough, you can create your in few lines.
|
|
1
|
+
# x4js
|
|
2
|
+
**A TypeScript framework for people who build applications, not markup.**
|
|
12
3
|
|
|
13
4
|
## Home page
|
|
14
5
|
see [home](https://x4js.org)
|
|
@@ -17,11 +8,100 @@ see [home](https://x4js.org)
|
|
|
17
8
|
see [API](https://rlibre.github.io/x4/index.html)
|
|
18
9
|
see [AI](./aicontext.md)
|
|
19
10
|
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## The translation you never signed up for
|
|
14
|
+
|
|
15
|
+
Modern web frameworks share a hidden assumption: that your job is to describe the DOM. Whether through JSX, templates, or reactive object trees, they all keep you close to the metal — writing `<div>`s, wiring class names, juggling attributes. You spend your days translating what you *mean* into how the browser *renders it*, then debugging the translation.
|
|
16
|
+
|
|
17
|
+
x4js removes that layer entirely.
|
|
18
|
+
|
|
19
|
+
You don't assemble elements. You compose high-level objects — `Button`, `DataGrid`, `Dialog`, `Form` — that model your application, not the page. A button has a `text`, not a `textContent`. A grid has columns, not a `<table><thead><tr>`. You describe intent; the framework handles the rest.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## The paradox nobody talks about
|
|
24
|
+
|
|
25
|
+
Millions of developers building with reactive frameworks — React, Vue, Svelte, and the rest — spend their day inside VS Code, Slack, Figma, Discord. Applications they hold up as benchmarks of speed and polish.
|
|
26
|
+
|
|
27
|
+
Not one of them is built the way those same developers build their own work.
|
|
28
|
+
|
|
29
|
+
VS Code is written in TypeScript, around high-level object components, with direct, surgical DOM manipulation — **no virtual DOM**. That isn't an accident. Routing every keystroke through a virtual-tree reconciliation pass would make a code editor unusable. So the tool you use to write your reactive app could never have been written in that same reactive framework and stayed fast.
|
|
30
|
+
|
|
31
|
+
This isn't an isolated curiosity. It's a pattern. The applications that must be genuinely fast — editors, IDEs, terminals, design tools, spreadsheets — converge on the same architecture again and again: object components, direct DOM updates, fine-grained and intentional reactivity. Exactly the philosophy x4js is built on.
|
|
32
|
+
|
|
33
|
+
The virtual DOM is a comfortable abstraction that trades performance for ease of reasoning. It's a fair trade for a landing page, a dashboard, a form. But the moment an interface becomes dense, dynamic, and latency-sensitive, the teams building the industry's reference tools quietly abandon that trade. They don't choose it for their own product — they only recommend it for everyone else's.
|
|
34
|
+
|
|
35
|
+
> When performance actually matters, the industry votes with its code.
|
|
36
|
+
> x4js brings to *your* applications the architecture your tools already use for theirs.
|
|
37
|
+
|
|
38
|
+
A fair caveat: VS Code doesn't run on x4js, and reactive frameworks aren't "bad." React, Vue, and Svelte optimize for a different problem — productivity on interfaces of moderate complexity — and they do it well. The point is architectural family, not brand. x4js belongs to the same lineage as the dense, demanding tools you already trust: object-first, direct-DOM, precisely reactive.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Why the object-first approach wins
|
|
43
|
+
|
|
44
|
+
### No virtual DOM, no diffing
|
|
45
|
+
|
|
46
|
+
x4js builds and updates the real DOM directly. There's no reconciliation pass, no shadow tree to compare against, no per-render overhead. Updates are direct — which makes them fast and, just as importantly, predictable.
|
|
47
|
+
|
|
48
|
+
### Code that runs when you call it — and only then
|
|
49
|
+
|
|
50
|
+
In a reactive framework, your component is a function the framework re-executes whenever it decides to. Every variable is recreated on each pass; every closure can silently capture a stale value. Entire categories of bugs — stale closures, dependency arrays, effects firing twice, memoization tuning — exist *only* because the execution model is inverted: the framework calls you.
|
|
51
|
+
|
|
52
|
+
An x4js component is an object. Its state lives in fields that persist for its whole lifetime. Its methods run when you call them, once, in the order you wrote. There is nothing to memoize, no dependency list to maintain, no rule about where you're allowed to declare things. The mental model is the one you already have from every other kind of software you've written.
|
|
53
|
+
|
|
54
|
+
### State you can simply assign
|
|
55
|
+
|
|
56
|
+
In reactive frameworks, changing a value is a discipline in itself. State is immutable, so a one-field update becomes a pyramid of spreads: `setUser({ ...user, address: { ...user.address, city: "Paris" } })` — and entire libraries exist just to make that bearable. State setters are asynchronous and batched, so the line after `setCount(count + 1)` still reads the old value; you must remember updater functions (`setCount(c => c + 1)`) or debug why your counter skips. And the primitives themselves come with a rulebook — hooks only at the top level, never in a condition or a loop, always in the same order — rules the language cannot enforce, so a linter has to police your code where the compiler should have.
|
|
57
|
+
|
|
58
|
+
None of this is your application. All of it is ceremony imposed by the framework's model of change.
|
|
59
|
+
|
|
60
|
+
In x4js, state is an object and change is assignment: `state.count++`, `state.user.address.city = "Paris"`. The value is updated on the next line, because of course it is. Deep mutation works, because objects are objects. There are no placement rules, because a property assignment is not a framework primitive — it's JavaScript. Years of accumulated workarounds — updater callbacks, spread pyramids, immutability helpers, exhaustive-deps lint rules — simply have nothing left to fix.
|
|
61
|
+
|
|
62
|
+
### Debugging your code, not the framework's
|
|
63
|
+
|
|
64
|
+
Set a breakpoint in an x4js method and the call stack reads like your application: your event handler, your component, your logic. In virtual-DOM frameworks, the same breakpoint lands you in a scheduler — fiber trees, hook lists, reconciliation frames — with your actual code buried somewhere inside the machinery. Dedicated browser devtools extensions exist for those frameworks precisely because the native debugger stopped being enough. x4js needs none: what you wrote is what executes, and the standard debugger tells the whole story.
|
|
65
|
+
|
|
66
|
+
### No re-render tax on memory
|
|
67
|
+
|
|
68
|
+
A virtual-DOM render allocates a full tree of description objects — on every update, for every component involved — and immediately hands it to the garbage collector once diffed. Under load (typing, scrolling, dragging), that is a constant allocation churn competing with your application for CPU and memory. x4js components are allocated once and mutated in place. A text change is a property assignment and one DOM write. Nothing to allocate, nothing to collect, nothing to diff.
|
|
69
|
+
|
|
70
|
+
### Built for dense, professional interfaces
|
|
71
|
+
|
|
72
|
+
Reactive ecosystems treat data grids, tree views, dockable panels, and virtual scrolling as third-party problems — an `npm install` and a licensing page away. That is because rendering ten thousand live rows through a diffing pipeline is genuinely hard, so the hard components get outsourced. In x4js, the demanding components are first-class citizens of the framework itself, built on the same direct-DOM foundation as everything else. Business applications — the ones with toolbars, grids, trees, and forms that people use eight hours a day — are the primary use case, not an afterthought.
|
|
73
|
+
|
|
74
|
+
### No build step required
|
|
75
|
+
|
|
76
|
+
No JSX to transpile, no template compiler, no toolchain to configure. x4js is TypeScript that runs. You get type safety and autocompletion out of the box, with nothing standing between your source and your running app — and nothing in your supply chain but the compiler you already trust.
|
|
77
|
+
|
|
78
|
+
### An API that survives the churn
|
|
79
|
+
|
|
80
|
+
The reactive world reinvents itself every few years: class components gave way to hooks, options API to composition API, stores to signals — each transition deprecating the patterns, tutorials, and muscle memory of the previous one. Code written against browser primitives and plain TypeScript objects doesn't churn like that. `Proxy`, `EventTarget`, classes, and the DOM are stable, standardized ground. An x4js codebase written today will compile and run unchanged years from now, because there is no framework-specific paradigm to be deprecated out from under it.
|
|
81
|
+
|
|
82
|
+
### You ship what you use — nothing else
|
|
83
|
+
|
|
84
|
+
Reactive frameworks make you ship their runtime before your first component renders: the scheduler, the reconciler, the hooks machinery — a fixed entry fee, paid by every user on every load, whether your page is an app or a button. x4js has no such core tax. Components are plain TypeScript classes, so standard tree-shaking keeps exactly what you import: use only `Button`, and your bundle contains `Button` — about 22 KB, core included. No runtime to amortize, no framework floor below which your app cannot shrink. Small tools stay small; big applications pay only for the components they actually use.
|
|
85
|
+
|
|
86
|
+
If you come from C or C++, you already know this model: it's **static linking against a library**. The linker pulls in only the symbols you reference, dead code never makes it into the binary. A reactive framework's runtime is the opposite — a mandatory DLL shipped whole with every executable, whether you call one function from it or a thousand. x4js links; it doesn't bundle a runtime.
|
|
87
|
+
|
|
88
|
+
### End-to-end TypeScript
|
|
89
|
+
|
|
90
|
+
Components, properties, and events are fully typed. The compiler catches mistakes before the browser does — not only in your logic, but in the way your components fit together.
|
|
91
|
+
|
|
92
|
+
### One codebase, web and desktop
|
|
93
|
+
|
|
94
|
+
Because x4js produces standard DOM from plain TypeScript — no framework-specific runtime, no build-time magic — the same application runs unchanged in the browser or packaged as a desktop app through Electron, Tauri, or NW.js. Nothing to adapt, nothing to port: the wrapper provides the window, x4js provides the application.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
**Think differently. Escape the DOM. Focus on the concept, not the format.**
|
|
99
|
+
|
|
20
100
|
## Ideas
|
|
21
101
|
|
|
22
102
|
[x] aicontext.md
|
|
23
|
-
[
|
|
24
|
-
[
|
|
103
|
+
[x] finish StateManager
|
|
104
|
+
[x] review core_data
|
|
25
105
|
[ ] doc doc and doc
|
|
26
106
|
[ ] demo with all best practices
|
|
27
107
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "x4js",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.34",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/x4.ts",
|
|
6
6
|
"module": "src/x4.ts",
|
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
"typescript": "^5.8.3"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"tsx": "^4.22.4",
|
|
40
39
|
"typedoc": "^0.28.19",
|
|
41
40
|
"typedoc-plugin-markdown": "^4.12.0"
|
|
42
41
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { class_ns
|
|
1
|
+
import { class_ns } from '../../core/core_tools';
|
|
2
|
+
import { Component, ComponentProps } from '../../core/component';
|
|
2
3
|
|
|
3
4
|
import Monaco from './bin/monaco';
|
|
4
5
|
import "./monaco.module.scss"
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
interface MonacoEditorProps extends ComponentProps {
|
|
8
|
-
language: "typescript" | "javascript" | "json" | "css" | "html";
|
|
9
|
+
language: "typescript" | "javascript" | "json" | "css" | "html" | string;
|
|
9
10
|
theme?: string;
|
|
10
11
|
content?: string;
|
|
11
12
|
options?: Monaco.editor.IEditorOptions & Monaco.editor.IGlobalEditorOptions;
|
|
@@ -14,24 +15,22 @@ interface MonacoEditorProps extends ComponentProps {
|
|
|
14
15
|
@class_ns( "x4" )
|
|
15
16
|
export class MonacoEditor extends Component<MonacoEditorProps> {
|
|
16
17
|
|
|
17
|
-
static
|
|
18
|
-
static
|
|
19
|
-
static
|
|
20
|
-
static
|
|
18
|
+
private static _initCount = 0;
|
|
19
|
+
private static _basePath: string = "monaco/";
|
|
20
|
+
private static _monaco: typeof Monaco;
|
|
21
|
+
private static _initCbs: Function[] = [];
|
|
21
22
|
|
|
22
23
|
private _editor: Monaco.editor.IStandaloneCodeEditor;
|
|
23
24
|
|
|
24
|
-
static async
|
|
25
|
-
if( this.
|
|
25
|
+
static async _start( ) {
|
|
26
|
+
if( this._initCount ) {
|
|
26
27
|
return;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
//
|
|
30
|
+
// dynamic import (without esbuild intervention)
|
|
30
31
|
const dynamicImport = new Function("path", "return import('./'+path)");
|
|
31
|
-
this.
|
|
32
|
-
|
|
33
|
-
//this.monaco = (await import( "./bin/monaco.js" )).default;
|
|
34
|
-
this.initCount++;
|
|
32
|
+
this._monaco = (await dynamicImport(this._basePath + "monaco.js")).default;
|
|
33
|
+
this._initCount++;
|
|
35
34
|
|
|
36
35
|
globalThis.MonacoEnvironment = {
|
|
37
36
|
getWorkerUrl: function (_moduleId, label) {
|
|
@@ -53,8 +52,8 @@ export class MonacoEditor extends Component<MonacoEditorProps> {
|
|
|
53
52
|
workerPath = "editor.worker.js";
|
|
54
53
|
}
|
|
55
54
|
|
|
56
|
-
const fullpath = MonacoEditor.
|
|
57
|
-
console.log( `getting "${label} path: ${fullpath}` );
|
|
55
|
+
const fullpath = MonacoEditor._basePath+'workers/'+workerPath;
|
|
56
|
+
//console.log( `getting "${label} path: ${fullpath}` );
|
|
58
57
|
return fullpath;
|
|
59
58
|
}
|
|
60
59
|
};
|
|
@@ -62,7 +61,7 @@ export class MonacoEditor extends Component<MonacoEditorProps> {
|
|
|
62
61
|
// custom append css
|
|
63
62
|
const link = document.createElement('link');
|
|
64
63
|
link.rel = 'stylesheet';
|
|
65
|
-
link.href = MonacoEditor.
|
|
64
|
+
link.href = MonacoEditor._basePath+'monaco.css';
|
|
66
65
|
link.type = 'text/css';
|
|
67
66
|
document.head.appendChild(link);
|
|
68
67
|
}
|
|
@@ -70,16 +69,16 @@ export class MonacoEditor extends Component<MonacoEditorProps> {
|
|
|
70
69
|
static addTypelib( name: string, code: string ) {
|
|
71
70
|
const register = ( ) => {
|
|
72
71
|
///@ts-ignore
|
|
73
|
-
const ts = MonacoEditor.
|
|
72
|
+
const ts = MonacoEditor._monaco.languages.typescript;
|
|
74
73
|
///@ts-ignore
|
|
75
74
|
ts.typescriptDefaults.addExtraLib( code, `ts:filename/${name}`);
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
if( MonacoEditor.
|
|
77
|
+
if( MonacoEditor._monaco ) {
|
|
79
78
|
register( );
|
|
80
79
|
}
|
|
81
80
|
else {
|
|
82
|
-
this.
|
|
81
|
+
this._initCbs.push( register );
|
|
83
82
|
}
|
|
84
83
|
}
|
|
85
84
|
|
|
@@ -89,11 +88,11 @@ export class MonacoEditor extends Component<MonacoEditorProps> {
|
|
|
89
88
|
|
|
90
89
|
super( props );
|
|
91
90
|
|
|
92
|
-
MonacoEditor.
|
|
91
|
+
MonacoEditor._start( )
|
|
93
92
|
.then( ( ) => {
|
|
94
|
-
MonacoEditor.
|
|
93
|
+
MonacoEditor._initCbs.forEach( x => x() );
|
|
95
94
|
|
|
96
|
-
this._editor = MonacoEditor.
|
|
95
|
+
this._editor = MonacoEditor._monaco.editor.create( this.dom as HTMLElement, {
|
|
97
96
|
value: content,
|
|
98
97
|
language: props.language,
|
|
99
98
|
theme: props.theme,
|
|
@@ -112,5 +111,25 @@ export class MonacoEditor extends Component<MonacoEditorProps> {
|
|
|
112
111
|
}
|
|
113
112
|
})
|
|
114
113
|
}
|
|
114
|
+
|
|
115
|
+
setTheme( name: string ) {
|
|
116
|
+
MonacoEditor._monaco.editor.setTheme( name );
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
queryInterface<T>(name: string): T {
|
|
120
|
+
if( name=='tab-handler' ) {
|
|
121
|
+
const rc = {
|
|
122
|
+
focusNext: ( ) : boolean => { return false; }
|
|
123
|
+
}
|
|
124
|
+
return rc as T;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return super.queryInterface( name );
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static async monaco( ) {
|
|
131
|
+
await this._start( );
|
|
132
|
+
return this._monaco;
|
|
133
|
+
}
|
|
115
134
|
}
|
|
116
135
|
|
package/src/core/component.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { AriaAttributes, unitless } from './core_styles';
|
|
|
20
20
|
import { CoreEvent, EventMap } from './core_events';
|
|
21
21
|
import { addEvent, DOMEventHandler, GlobalDOMEvents } from './core_dom';
|
|
22
22
|
import { Application, EvMessage } from './core_application';
|
|
23
|
+
import { makeState } from './core_state.js';
|
|
23
24
|
|
|
24
25
|
interface RefType<T extends Component> {
|
|
25
26
|
dom: T;
|
|
@@ -164,7 +165,7 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
|
|
|
164
165
|
protected readonly clsprefix: string; // internal class name prefix (x4 internal)
|
|
165
166
|
|
|
166
167
|
#store: Map<string|symbol,any>;
|
|
167
|
-
|
|
168
|
+
#pstate: any;
|
|
168
169
|
|
|
169
170
|
constructor( props: P ) {
|
|
170
171
|
super( );
|
|
@@ -1156,6 +1157,41 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
|
|
|
1156
1157
|
queryInterface<T>( name: string ): T {
|
|
1157
1158
|
return null;
|
|
1158
1159
|
}
|
|
1160
|
+
|
|
1161
|
+
// :: PERSISTENCE ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
1162
|
+
|
|
1163
|
+
protected loadPState( name: string, defaults: Record<string, any> ): any {
|
|
1164
|
+
|
|
1165
|
+
if( !this.#pstate ) {
|
|
1166
|
+
this.#pstate = {};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if( this.#pstate[name] ) {
|
|
1170
|
+
return this.#pstate[name];
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const key = `x4@persist:${name}`;
|
|
1174
|
+
|
|
1175
|
+
let raw: Record<string, any>;
|
|
1176
|
+
try {
|
|
1177
|
+
const stored = Application.instance().getStorage( key );
|
|
1178
|
+
raw = stored ? { ...defaults, ...JSON.parse( stored ) } : { ...defaults };
|
|
1179
|
+
}
|
|
1180
|
+
catch {
|
|
1181
|
+
raw = { ...defaults };
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const state = makeState( raw );
|
|
1185
|
+
|
|
1186
|
+
state.on( "change", ( ) => {
|
|
1187
|
+
this.setTimeout( key, 500, ( ) => {
|
|
1188
|
+
Application.instance().setStorage( key, JSON.stringify( raw ) );
|
|
1189
|
+
});
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
this.#pstate[name] = state;
|
|
1193
|
+
return state;
|
|
1194
|
+
}
|
|
1159
1195
|
}
|
|
1160
1196
|
|
|
1161
1197
|
|
|
@@ -155,7 +155,7 @@ export class Application<E extends ApplicationEvents = ApplicationEvents> extend
|
|
|
155
155
|
* @param value - The value to store for the environment variable.
|
|
156
156
|
*/
|
|
157
157
|
|
|
158
|
-
setEnv( name: string, value:
|
|
158
|
+
setEnv<T = any>( name: string, value: T ) {
|
|
159
159
|
this.env.set( name, value );
|
|
160
160
|
}
|
|
161
161
|
|
|
@@ -166,7 +166,7 @@ export class Application<E extends ApplicationEvents = ApplicationEvents> extend
|
|
|
166
166
|
* @returns The value of the environment variable, or `def_value` if not found.
|
|
167
167
|
*/
|
|
168
168
|
|
|
169
|
-
getEnv( name: string, def_value?:
|
|
169
|
+
getEnv<T = any>( name: string, def_value?: T ) : T {
|
|
170
170
|
return this.env.get( name ) ?? def_value;
|
|
171
171
|
}
|
|
172
172
|
|
package/src/core/core_state.ts
CHANGED
|
@@ -4,64 +4,227 @@
|
|
|
4
4
|
* @copyright (c) 2025 R-libre ingenierie, all rights reserved.
|
|
5
5
|
**/
|
|
6
6
|
|
|
7
|
+
import { CoreEvent, EventMap, EventSource } from './core_events.js';
|
|
8
|
+
import { isPlainObject } from './core_tools.js';
|
|
9
|
+
|
|
10
|
+
type StateData = boolean | number | string | Date | unknown;
|
|
11
|
+
type State = Record<string, StateData>;
|
|
12
|
+
|
|
13
|
+
export interface EvStateChange extends CoreEvent {
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly value: any;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StateEvents extends EventMap {
|
|
19
|
+
change: EvStateChange;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* type of the proxified state: the data itself + the manager's event API.
|
|
24
|
+
* Pick<> avoids re-declaring the signatures — if on/off/once evolve
|
|
25
|
+
* in StateManager, this type follows automatically.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type StateProxy<T extends State> = T & Pick<StateManager<T>, "on" | "off" | "once" | "watch">;
|
|
7
29
|
|
|
8
|
-
type StateData = boolean | number | string | Date | any;
|
|
9
|
-
type State = Record<string,StateData>;
|
|
10
30
|
|
|
11
31
|
/**
|
|
12
|
-
*
|
|
32
|
+
* true for values that must be wrapped in a proxy when accessed
|
|
33
|
+
* (plain objects & arrays — excludes null, Date, RegExp, Map, Set,
|
|
34
|
+
* class instances, ...)
|
|
13
35
|
*/
|
|
14
36
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
private _subscribers: Map<string,any>;
|
|
19
|
-
private _currentTracking: Set<string>;
|
|
37
|
+
function _is_proxyable( value: any ): boolean {
|
|
38
|
+
return value !== null && typeof value === "object" && ( Array.isArray( value ) || isPlainObject( value ) );
|
|
39
|
+
}
|
|
20
40
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
41
|
+
/**
|
|
42
|
+
* append a segment to a dotted path, using bracket notation
|
|
43
|
+
* for array indices: "items[3].name" instead of "items.3.name"
|
|
44
|
+
*/
|
|
45
|
+
function _child_path( path: string, prop: string, target: any ): string {
|
|
46
|
+
if( Array.isArray( target ) && /^\d+$/.test( prop ) ) {
|
|
47
|
+
return `${path}[${prop}]`;
|
|
25
48
|
}
|
|
49
|
+
return path ? `${path}.${prop}` : prop;
|
|
50
|
+
}
|
|
26
51
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
/**
|
|
53
|
+
* true when 'path' is 'watched' itself or one of its descendants:
|
|
54
|
+
* _path_matches( "user", "user" ) → true (the value itself)
|
|
55
|
+
* _path_matches( "user.name", "user" ) → true (descendant)
|
|
56
|
+
* _path_matches( "user.tags[2]", "user.tags") → true (array element)
|
|
57
|
+
* _path_matches( "username", "user" ) → false (boundary check)
|
|
58
|
+
*/
|
|
59
|
+
function _path_matches( path: string, watched: string ): boolean {
|
|
60
|
+
if( !path.startsWith(watched) ) return false;
|
|
61
|
+
if( path.length===watched.length ) return true;
|
|
62
|
+
|
|
63
|
+
const c = path[watched.length];
|
|
64
|
+
return c==='.' || c==='[';
|
|
65
|
+
}
|
|
41
66
|
|
|
42
|
-
|
|
67
|
+
/**
|
|
68
|
+
*
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
export class StateManager<T extends State> extends EventSource<StateEvents> {
|
|
72
|
+
|
|
73
|
+
private _state: T;
|
|
74
|
+
private _proxy: StateProxy<T> | undefined;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* raw object → proxy cache, scoped to THIS manager
|
|
78
|
+
* - avoids re-creating a proxy on every get
|
|
79
|
+
* - guarantees referential identity: state.user === state.user
|
|
80
|
+
* - WeakMap: entries are collected with their objects, and the whole cache is collected with the manager
|
|
81
|
+
* - instance-scoped (not module-level) so the same raw object referenced by two different managers never gets a proxy bound to the wrong manager/path
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
private _cache = new WeakMap<object, any>( );
|
|
85
|
+
|
|
86
|
+
constructor( initialState: T ) {
|
|
87
|
+
super( );
|
|
88
|
+
this._state = { ...initialState };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
proxify( ): StateProxy<T> {
|
|
92
|
+
|
|
93
|
+
if( !this._proxy ) {
|
|
94
|
+
// event API attached as non-enumerable properties on the raw root object: invisible to for...in / Object.keys
|
|
95
|
+
// OR JSON.stringify, and served by the get trap with no special handling
|
|
96
|
+
|
|
97
|
+
Object.defineProperties( this._state, {
|
|
98
|
+
on: { value: this.on.bind( this ), enumerable: false },
|
|
99
|
+
off: { value: this.off.bind( this ), enumerable: false },
|
|
100
|
+
once: { value: this.once.bind( this ), enumerable: false },
|
|
101
|
+
watch: { value: this.watch.bind( this ), enumerable: false },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
this._proxy = this._mk_proxy( this._state, "" );
|
|
43
105
|
}
|
|
44
106
|
|
|
45
|
-
return
|
|
107
|
+
return this._proxy;
|
|
46
108
|
}
|
|
47
109
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
110
|
+
/**
|
|
111
|
+
* create a lazy proxy: sub-objects and arrays are only wrapped
|
|
112
|
+
* when actually accessed, and are never copied — the original
|
|
113
|
+
* object remains the single source of truth.
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
private _mk_proxy( obj: any, path: string ): any {
|
|
117
|
+
|
|
118
|
+
const cached = this._cache.get( obj );
|
|
119
|
+
if( cached ) return cached;
|
|
120
|
+
|
|
121
|
+
const proxy = new Proxy( obj, {
|
|
52
122
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
123
|
+
get: ( target, prop, receiver ) => {
|
|
124
|
+
|
|
125
|
+
// symbols (Symbol.iterator, Symbol.toPrimitive, devtools inspection...):
|
|
126
|
+
// pass-through, never an error nor a wrap
|
|
127
|
+
if( typeof prop === "symbol" ) {
|
|
128
|
+
return Reflect.get( target, prop, receiver );
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// genuinely missing property ('in' walks the prototype chain, so array methods, toString, toJSON... never trigger a false positive)
|
|
132
|
+
if( !( prop in target ) ) {
|
|
133
|
+
console.error( `state error, unable to find ${_child_path(path,prop,target)}` );
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const value = Reflect.get( target, prop, receiver );
|
|
138
|
+
|
|
139
|
+
// wrap on demand, only what is actually read
|
|
140
|
+
if( _is_proxyable( value ) ) {
|
|
141
|
+
return this._mk_proxy( value, _child_path( path, prop, target ) );
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return value;
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
set: ( target, prop, value, receiver ) => {
|
|
148
|
+
|
|
149
|
+
// no notification when the value doesn't change
|
|
150
|
+
// (avoids noise and listener → set → listener loops)
|
|
151
|
+
if( Reflect.get( target, prop, receiver ) === value ) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
Reflect.set( target, prop, value, receiver );
|
|
156
|
+
|
|
157
|
+
// no readable path for a symbol: assign without firing
|
|
158
|
+
if( typeof prop === "string" ) {
|
|
159
|
+
this.fire( "change", { path: _child_path(path,prop,target), value } );
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return true;
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
deleteProperty: ( target, prop ) => {
|
|
166
|
+
|
|
167
|
+
if( !( prop in target ) ) return true;
|
|
168
|
+
|
|
169
|
+
delete target[prop];
|
|
170
|
+
|
|
171
|
+
if( typeof prop === "string" ) {
|
|
172
|
+
this.fire( "change", { path: _child_path(path,prop,target), value: undefined } );
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return true;
|
|
57
176
|
}
|
|
58
|
-
|
|
59
|
-
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
this._cache.set( obj, proxy );
|
|
180
|
+
return proxy;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
on<K extends keyof StateEvents>( name: K, listener: ( ev: StateEvents[K] ) => void ) {
|
|
184
|
+
this.addListener( name, listener );
|
|
185
|
+
return {
|
|
186
|
+
off: ( ) => this.removeListener( name, listener )
|
|
60
187
|
}
|
|
188
|
+
}
|
|
61
189
|
|
|
62
|
-
|
|
190
|
+
off<K extends keyof StateEvents>( name: K, listener: ( ev: StateEvents[K] ) => void ) {
|
|
191
|
+
this.removeListener( name, listener );
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
once<K extends keyof StateEvents>( name: K, listener: ( ev: StateEvents[K] ) => void ) {
|
|
195
|
+
const handle = this.on( name, ( e ) => {
|
|
196
|
+
handle.off( );
|
|
197
|
+
listener( e );
|
|
198
|
+
});
|
|
199
|
+
return handle;
|
|
200
|
+
}
|
|
63
201
|
|
|
64
|
-
|
|
65
|
-
|
|
202
|
+
/**
|
|
203
|
+
* observe changes on a specific path and everything below it.
|
|
204
|
+
* fires for the path itself and for any descendant:
|
|
205
|
+
* watch( "user", cb ) → fires on user, user.name, user.tags[0], ...
|
|
206
|
+
* returns a handle with off() to unsubscribe.
|
|
207
|
+
*/
|
|
208
|
+
watch( path: string, cb: ( ev: EvStateChange ) => void ) {
|
|
209
|
+
return this.on( "change", e => {
|
|
210
|
+
if( _path_matches( e.path, path ) ) {
|
|
211
|
+
cb( e );
|
|
212
|
+
}
|
|
213
|
+
});
|
|
66
214
|
}
|
|
67
|
-
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* create a ready-to-use reactive state
|
|
220
|
+
*
|
|
221
|
+
* const state = makeState( { count: 0, items: [1,2,3] } );
|
|
222
|
+
* state.on( "change", e => console.log( e.path, "=", e.value ) );
|
|
223
|
+
* state.count++; // "count = 1"
|
|
224
|
+
* state.items.push( 4 ); // "items.3 = 4"
|
|
225
|
+
*/
|
|
226
|
+
|
|
227
|
+
export function makeState<T extends State>( initialState: T ): StateProxy<T> {
|
|
228
|
+
return new StateManager( initialState ).proxify( );
|
|
229
|
+
}
|
|
230
|
+
|
package/src/core/core_tools.ts
CHANGED
|
@@ -16,6 +16,30 @@
|
|
|
16
16
|
|
|
17
17
|
import { _tr } from "./core_i18n.js";
|
|
18
18
|
|
|
19
|
+
|
|
20
|
+
// todo: split into 2 files
|
|
21
|
+
// core_tools & core_dom_tools
|
|
22
|
+
|
|
23
|
+
// isFeatureAvailable → "EyeDropper" in window
|
|
24
|
+
// asap → requestAnimationFrame (n'existe pas en Node ; Deno l'a)
|
|
25
|
+
// beep → new Audio(...) + .play()
|
|
26
|
+
// getScrollbarSize → document.createElement, document.body
|
|
27
|
+
// getGlobalZoom → window.getComputedStyle, document.body
|
|
28
|
+
// getSystemMetrics → appelle les deux précédentes
|
|
29
|
+
// setWaitCursor → document.body.style
|
|
30
|
+
// getFocusableElements → root.querySelectorAll, HTMLElement
|
|
31
|
+
// Element en paramètre de type (DOM lib)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
19
43
|
/**
|
|
20
44
|
* @returns true if object is a string
|
|
21
45
|
*/
|
|
@@ -328,9 +352,12 @@ export class Timer {
|
|
|
328
352
|
this.clearTimeout(name);
|
|
329
353
|
}
|
|
330
354
|
|
|
331
|
-
const tm = setTimeout(
|
|
332
|
-
|
|
355
|
+
const tm = setTimeout( ( ) => {
|
|
356
|
+
this._timers?.delete( name );
|
|
357
|
+
callback( );
|
|
358
|
+
}, time);
|
|
333
359
|
|
|
360
|
+
this._timers.set(name, tm);
|
|
334
361
|
return tm;
|
|
335
362
|
}
|
|
336
363
|
|
|
@@ -373,6 +400,10 @@ export class Timer {
|
|
|
373
400
|
|
|
374
401
|
this._timers = null;
|
|
375
402
|
}
|
|
403
|
+
|
|
404
|
+
debounce( name: string, time: number, callback: Function ) {
|
|
405
|
+
this.setTimeout( name, time, callback );
|
|
406
|
+
}
|
|
376
407
|
}
|
|
377
408
|
|
|
378
409
|
/**
|
|
@@ -1018,4 +1049,98 @@ export function sanitizeHtml( input: string ): string {
|
|
|
1018
1049
|
export function safeText( v: string | UnsafeHtml ): string {
|
|
1019
1050
|
if( !v ) { return ""; }
|
|
1020
1051
|
return v instanceof UnsafeHtml ? v.toString() : sanitizeHtml( v );
|
|
1021
|
-
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* split a dotted path into segments.
|
|
1057
|
+
* "user.name" → [ "user", "name" ]
|
|
1058
|
+
* "user.tags[2]" → [ "user", "tags", "2" ]
|
|
1059
|
+
* "items[0][1]" → [ "items", "0", "1" ]
|
|
1060
|
+
*/
|
|
1061
|
+
function _parse_path( path: string ): string[] {
|
|
1062
|
+
|
|
1063
|
+
const segments: string[] = [];
|
|
1064
|
+
|
|
1065
|
+
for( const part of path.split( '.' ) ) {
|
|
1066
|
+
// "tags[2]" → "tags" then "2" ; "name" → "name"
|
|
1067
|
+
let rest = part;
|
|
1068
|
+
|
|
1069
|
+
const bracket = rest.indexOf( '[' );
|
|
1070
|
+
if( bracket < 0 ) {
|
|
1071
|
+
segments.push( rest );
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if( bracket > 0 ) {
|
|
1076
|
+
segments.push( rest.substring( 0, bracket ) );
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// consume every [n] group
|
|
1080
|
+
const re = /\[(\d+)\]/g;
|
|
1081
|
+
let m;
|
|
1082
|
+
while( ( m = re.exec( rest ) ) !== null ) {
|
|
1083
|
+
segments.push( m[1] );
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
return segments;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* walk down 'obj' following 'segments', stopping before the last one.
|
|
1093
|
+
* returns the parent object of the final segment, or undefined if
|
|
1094
|
+
* the path is broken somewhere.
|
|
1095
|
+
*/
|
|
1096
|
+
function _walk_to_parent( obj: any, segments: string[] ): any {
|
|
1097
|
+
|
|
1098
|
+
let current = obj;
|
|
1099
|
+
|
|
1100
|
+
for( let i = 0; i < segments.length - 1; i++ ) {
|
|
1101
|
+
if( current === null || current === undefined ) {
|
|
1102
|
+
return undefined;
|
|
1103
|
+
}
|
|
1104
|
+
current = current[segments[i]];
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
return current;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* read the value at the given path.
|
|
1113
|
+
* getMemberValue( state, "user.tags[2]" )
|
|
1114
|
+
*/
|
|
1115
|
+
|
|
1116
|
+
export function getMemberValue( obj: any, path: string ): any {
|
|
1117
|
+
|
|
1118
|
+
const segments = _parse_path( path );
|
|
1119
|
+
const parent = _walk_to_parent( obj, segments );
|
|
1120
|
+
|
|
1121
|
+
if( parent === undefined || parent === null ) {
|
|
1122
|
+
return undefined;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
return parent[ segments[segments.length - 1] ];
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* write the value at the given path.
|
|
1131
|
+
* silently does nothing if an intermediate segment is missing
|
|
1132
|
+
*/
|
|
1133
|
+
|
|
1134
|
+
export function setMemberValue( obj: any, path: string, value: any ) : boolean {
|
|
1135
|
+
|
|
1136
|
+
const segments = _parse_path( path );
|
|
1137
|
+
const parent = _walk_to_parent( obj, segments );
|
|
1138
|
+
|
|
1139
|
+
if( parent === undefined || parent === null ) {
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
parent[ segments[segments.length - 1] ] = value;
|
|
1144
|
+
return true;
|
|
1145
|
+
}
|
|
1146
|
+
|