aberdeen 0.4.0 → 0.5.0
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/LICENSE.txt +1 -1
- package/README.md +134 -96
- package/dist/aberdeen.d.ts +637 -528
- package/dist/aberdeen.js +1144 -1957
- package/dist/aberdeen.js.map +11 -1
- package/dist/helpers/reverseSortedSet.d.ts +91 -0
- package/dist/prediction.d.ts +7 -3
- package/dist/prediction.js +77 -93
- package/dist/prediction.js.map +10 -1
- package/dist/route.d.ts +36 -19
- package/dist/route.js +131 -158
- package/dist/route.js.map +10 -1
- package/dist/transitions.js +30 -70
- package/dist/transitions.js.map +10 -1
- package/dist-min/aberdeen.js +7 -2
- package/dist-min/aberdeen.js.map +11 -1
- package/dist-min/prediction.js +4 -2
- package/dist-min/prediction.js.map +10 -1
- package/dist-min/route.js +4 -2
- package/dist-min/route.js.map +10 -1
- package/dist-min/transitions.js +4 -2
- package/dist-min/transitions.js.map +10 -1
- package/package.json +20 -23
- package/src/aberdeen.ts +1918 -1814
- package/src/helpers/reverseSortedSet.ts +188 -0
- package/src/prediction.ts +14 -9
- package/src/route.ts +81 -64
- package/src/transitions.ts +1 -14
- package/dist-min/aberdeen.d.ts +0 -601
- package/dist-min/prediction.d.ts +0 -29
- package/dist-min/route.d.ts +0 -30
- package/dist-min/transitions.d.ts +0 -18
package/src/aberdeen.ts
CHANGED
|
@@ -1,2185 +1,2289 @@
|
|
|
1
|
-
|
|
1
|
+
import { ReverseSortedSet } from "./helpers/reverseSortedSet.js";
|
|
2
2
|
|
|
3
3
|
/*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
* QueueRunner
|
|
5
|
+
*
|
|
6
|
+
* `queue()`d runners are executed on the next timer tick, by order of their
|
|
7
|
+
* `prio` values.
|
|
8
|
+
*/
|
|
9
9
|
interface QueueRunner {
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
prio: number; // Higher values have higher priority
|
|
11
|
+
queueRun(): void;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
let
|
|
15
|
-
let queueIndex = 0 // This first element in queueArray that still needs to be processed.
|
|
16
|
-
let queueSet: Set<QueueRunner> = new Set() // Contains the subset of queueArray at index >= queueIndex.
|
|
17
|
-
let queueOrdered = true // Set to `false` when `queue()` appends a runner to `queueArray` that should come before the previous last item in the array. Will trigger a sort.
|
|
14
|
+
let sortedQueue: ReverseSortedSet<QueueRunner> | undefined; // When set, a runQueue is scheduled or currently running.
|
|
18
15
|
let runQueueDepth = 0 // Incremented when a queue event causes another queue event to be added. Reset when queue is empty. Throw when >= 42 to break (infinite) recursion.
|
|
19
|
-
let
|
|
20
|
-
|
|
16
|
+
let topRedrawScope: Scope | undefined // The scope that triggered the current redraw. Elements drawn at this scope level may trigger 'create' animations.
|
|
21
17
|
|
|
22
18
|
/** @internal */
|
|
23
|
-
export type
|
|
19
|
+
export type TargetType = any[] | {[key: string]: any};
|
|
20
|
+
/** @internal */
|
|
21
|
+
export type DatumType = TargetType | boolean | number | string | null | undefined;
|
|
24
22
|
|
|
25
23
|
function queue(runner: QueueRunner) {
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
else if (runner._queueOrder < queueArray[queueArray.length-1]._queueOrder) {
|
|
34
|
-
queueOrdered = false
|
|
35
|
-
}
|
|
36
|
-
queueArray.push(runner)
|
|
37
|
-
queueSet.add(runner)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Normally, changes to `Store`s are reacted to asynchronously, in an (optimized)
|
|
42
|
-
* batch, after a timeout of 0s. Calling `runQueue()` will do so immediately
|
|
43
|
-
* and synchronously. Doing so may be helpful in cases where you need some DOM
|
|
44
|
-
* modification to be done synchronously.
|
|
45
|
-
*
|
|
46
|
-
* This function is re-entrant, meaning it is safe to call `runQueue` from a
|
|
47
|
-
* function that is called due to another (automatic) invocation of `runQueue`.
|
|
48
|
-
*/
|
|
49
|
-
export function runQueue(): void {
|
|
50
|
-
showCreateTransitions = true
|
|
51
|
-
for(; queueIndex < queueArray.length; ) {
|
|
52
|
-
// Sort queue if new unordered items have been added since last time.
|
|
53
|
-
if (!queueOrdered) {
|
|
54
|
-
queueArray.splice(0, queueIndex)
|
|
55
|
-
queueIndex = 0
|
|
56
|
-
// Order queued observers by depth, lowest first.
|
|
57
|
-
queueArray.sort((a,b) => a._queueOrder - b._queueOrder)
|
|
58
|
-
queueOrdered = true
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Process the rest of what's currently in the queue.
|
|
62
|
-
let batchEndIndex = queueArray.length
|
|
63
|
-
while(queueIndex < batchEndIndex && queueOrdered) {
|
|
64
|
-
let runner = queueArray[queueIndex++]
|
|
65
|
-
queueSet.delete(runner)
|
|
66
|
-
runner._queueRun()
|
|
24
|
+
if (!sortedQueue) {
|
|
25
|
+
sortedQueue = new ReverseSortedSet<QueueRunner>('prio');
|
|
26
|
+
setTimeout(runQueue, 0);
|
|
27
|
+
} else if (!(runQueueDepth&1)) {
|
|
28
|
+
runQueueDepth++; // Make it uneven
|
|
29
|
+
if (runQueueDepth > 98) {
|
|
30
|
+
throw new Error("Too many recursive updates from observes");
|
|
67
31
|
}
|
|
68
|
-
|
|
69
|
-
// If new items have been added to the queue while processing the previous
|
|
70
|
-
// batch, we'll need to run this loop again.
|
|
71
|
-
runQueueDepth++
|
|
72
32
|
}
|
|
73
|
-
|
|
74
|
-
queueIndex = 0
|
|
75
|
-
queueArray.length = 0
|
|
76
|
-
runQueueDepth = 0
|
|
77
|
-
showCreateTransitions = false
|
|
33
|
+
sortedQueue.add(runner);
|
|
78
34
|
}
|
|
79
35
|
|
|
80
|
-
|
|
81
|
-
let domWaiters: (() => void)[] = []
|
|
82
|
-
let domInReadPhase = false
|
|
83
|
-
|
|
84
36
|
/**
|
|
85
|
-
*
|
|
86
|
-
* of DOM-write operations has completed. This is the best time to retrieve DOM properties
|
|
87
|
-
* that dependent on a layout being completed, such as `offsetHeight`.
|
|
37
|
+
* Forces the immediate and synchronous execution of all pending reactive updates.
|
|
88
38
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* intermediate DOM states during updates.
|
|
39
|
+
* Normally, changes to observed data sources (like proxied objects or arrays)
|
|
40
|
+
* are processed asynchronously in a batch after a brief timeout (0ms). This function
|
|
41
|
+
* allows you to bypass the timeout and process the update queue immediately.
|
|
93
42
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* See `transitions.js` for some examples.
|
|
98
|
-
*/
|
|
99
|
-
|
|
100
|
-
export const DOM_READ_PHASE = {
|
|
101
|
-
then: function(fulfilled: () => void) {
|
|
102
|
-
if (domInReadPhase) fulfilled()
|
|
103
|
-
else {
|
|
104
|
-
if (!domWaiters.length) queue(DOM_PHASE_RUNNER)
|
|
105
|
-
domWaiters.push(fulfilled)
|
|
106
|
-
}
|
|
107
|
-
return this
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* A promise-like object that you can `await`. It will resolve *after* the current
|
|
112
|
-
* DOM_READ_PHASE has completed (if any) and after any DOM triggered by Aberdeen
|
|
113
|
-
* have completed. This is a good time to do little manual DOM tweaks that depend
|
|
114
|
-
* on a *read phase* first, like triggering transitions.
|
|
43
|
+
* This can be useful in specific scenarios where you need the DOM to be updated
|
|
44
|
+
* synchronously.
|
|
115
45
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* intermediate DOM states during updates.
|
|
46
|
+
* This function is re-entrant, meaning it is safe to call `runQueue` from within
|
|
47
|
+
* a function that is itself being executed as part of an update cycle triggered
|
|
48
|
+
* by a previous (or the same) `runQueue` call.
|
|
120
49
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```typescript
|
|
52
|
+
* const data = proxy("before");
|
|
53
|
+
*
|
|
54
|
+
* $({text: data});
|
|
55
|
+
* console.log(1, document.body.innerHTML); // before
|
|
123
56
|
*
|
|
124
|
-
*
|
|
57
|
+
* // Make an update that should cause the DOM to change.
|
|
58
|
+
* data.value = "after";
|
|
59
|
+
*
|
|
60
|
+
* // Normally, the DOM update would happen after a timeout.
|
|
61
|
+
* // But this causes an immediate update:
|
|
62
|
+
* runQueue();
|
|
63
|
+
*
|
|
64
|
+
* console.log(2, document.body.innerHTML); // after
|
|
65
|
+
* ```
|
|
125
66
|
*/
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const DOM_PHASE_RUNNER = {
|
|
139
|
-
_queueOrder: 99999,
|
|
140
|
-
_queueRun: function() {
|
|
141
|
-
let waiters = domWaiters
|
|
142
|
-
domWaiters = []
|
|
143
|
-
domInReadPhase = !domInReadPhase
|
|
144
|
-
for(let waiter of waiters) {
|
|
145
|
-
try {
|
|
146
|
-
waiter()
|
|
147
|
-
} catch(e) {
|
|
148
|
-
console.error(e)
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
67
|
+
export function runQueue(): void {
|
|
68
|
+
let time = Date.now();
|
|
69
|
+
while(sortedQueue) {
|
|
70
|
+
const runner = sortedQueue.fetchLast();
|
|
71
|
+
if (!runner) break;
|
|
72
|
+
if (runQueueDepth&1) runQueueDepth++; // Make it even
|
|
73
|
+
runner.queueRun();
|
|
74
|
+
}
|
|
75
|
+
sortedQueue = undefined;
|
|
76
|
+
runQueueDepth = 0;
|
|
77
|
+
time = Date.now() - time;
|
|
78
|
+
if (time>1) console.debug(`Aberdeen queue took ${time}ms`);
|
|
152
79
|
}
|
|
153
80
|
|
|
154
81
|
|
|
155
|
-
/** @internal */
|
|
156
|
-
type SortKeyType = number | string | Array<number|string>
|
|
157
|
-
|
|
158
|
-
|
|
159
82
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
83
|
+
* A sort key, as used by {@link onEach}, is a value that determines the order of items. It can
|
|
84
|
+
* be a number, string, or an array of numbers/strings. The sort key is used to sort items
|
|
85
|
+
* based on their values. The sort key can also be `undefined`, which indicates that the item
|
|
86
|
+
* should be ignored.
|
|
87
|
+
* @private
|
|
163
88
|
*/
|
|
164
|
-
|
|
165
|
-
if (key instanceof Array) {
|
|
166
|
-
return key.map(partToStr).join('')
|
|
167
|
-
} else {
|
|
168
|
-
return partToStr(key)
|
|
169
|
-
}
|
|
170
|
-
}
|
|
89
|
+
export type SortKeyType = number | string | Array<number|string> | undefined;
|
|
171
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Given an integer number or a string, this function returns a string that can be concatenated
|
|
93
|
+
* with other strings to create a composed sort key, that follows natural number ordering.
|
|
94
|
+
*/
|
|
172
95
|
function partToStr(part: number|string): string {
|
|
173
96
|
if (typeof part === 'string') {
|
|
174
|
-
return part + '\x01'
|
|
175
|
-
} else {
|
|
176
|
-
let result = numToString(Math.abs(Math.round(part)), part<0)
|
|
177
|
-
// Prefix the number of digits, counting down from 128 for negative and up for positive
|
|
178
|
-
return String.fromCharCode(128 + (part>0 ? result.length : -result.length)) + result
|
|
97
|
+
return part + '\x01'; // end-of-string
|
|
179
98
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
let result = ''
|
|
99
|
+
let result = '';
|
|
100
|
+
let num = Math.abs(Math.round(part));
|
|
101
|
+
const negative = part < 0;
|
|
184
102
|
while(num > 0) {
|
|
185
103
|
/*
|
|
186
104
|
* We're reserving a few character codes:
|
|
187
105
|
* 0 - for compatibility
|
|
188
|
-
* 1 - separator between array items
|
|
106
|
+
* 1 - separator between string array items
|
|
189
107
|
* 65535 - for compatibility
|
|
190
108
|
*/
|
|
191
|
-
result += String.fromCharCode(
|
|
192
|
-
num = Math.floor(num / 65533)
|
|
109
|
+
result += String.fromCharCode(negative ? 65534 - (num % 65533) : 2 + (num % 65533));
|
|
110
|
+
num = Math.floor(num / 65533);
|
|
193
111
|
}
|
|
194
|
-
|
|
112
|
+
// Prefix the number of digits, counting down from 128 for negative and up for positive
|
|
113
|
+
return String.fromCharCode(128 + (negative ? -result.length : result.length)) + result;
|
|
195
114
|
}
|
|
196
115
|
|
|
197
|
-
/**
|
|
198
|
-
|
|
199
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Creates a new string that has the opposite sort order compared to the input string.
|
|
118
|
+
*
|
|
119
|
+
* This is achieved by flipping the bits of each character code in the input string.
|
|
120
|
+
* The resulting string is intended for use as a sort key, particularly with the
|
|
121
|
+
* `makeKey` function in {@link onEach}, to achieve a descending sort order.
|
|
122
|
+
*
|
|
123
|
+
* **Warning:** The output string will likely contain non-printable characters or
|
|
124
|
+
* appear as gibberish and should not be displayed to the user.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```typescript
|
|
128
|
+
* const users = proxy([
|
|
129
|
+
* { id: 1, name: 'Charlie', score: 95 },
|
|
130
|
+
* { id: 2, name: 'Alice', score: 100 },
|
|
131
|
+
* { id: 3, name: 'Bob', score: 90 },
|
|
132
|
+
* ]);
|
|
133
|
+
*
|
|
134
|
+
* onEach(users, (user) => {
|
|
135
|
+
* $(`p:${user.name}: ${user.score}`);
|
|
136
|
+
* }, (user) => invertString(user.name)); // Reverse alphabetic order
|
|
137
|
+
* ```
|
|
138
|
+
*
|
|
139
|
+
* @param input The string whose sort order needs to be inverted.
|
|
140
|
+
* @returns A new string that will sort in the reverse order of the input string.
|
|
141
|
+
* @see {@link onEach} for usage with sorting.
|
|
142
|
+
*/
|
|
143
|
+
export function invertString(input: string): string {
|
|
144
|
+
let result = '';
|
|
145
|
+
for (let i = 0; i < input.length; i++) {
|
|
146
|
+
result += String.fromCodePoint(65535 - input.charCodeAt(i));
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
200
149
|
}
|
|
201
150
|
|
|
202
|
-
/*
|
|
203
|
-
* Scope
|
|
204
|
-
* @internal
|
|
205
|
-
*
|
|
206
|
-
* A `Scope` is created with a `render` function that is run initially,
|
|
207
|
-
* and again when any of the `Store`s that this function reads are changed. Any
|
|
208
|
-
* DOM elements that is given a `render` function for its contents has its own scope.
|
|
209
|
-
* The `Scope` manages the position in the DOM tree elements created by `render`
|
|
210
|
-
* are inserted at. Before a rerender, all previously created elements are removed
|
|
211
|
-
* and the `clean` functions for the scope and all sub-scopes are called.
|
|
212
|
-
*/
|
|
213
151
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
152
|
+
// Each new scope gets a lower prio than all scopes before it, by decrementing
|
|
153
|
+
// this counter.
|
|
154
|
+
let lastPrio = 0;
|
|
217
155
|
|
|
218
|
-
|
|
219
|
-
// be
|
|
220
|
-
|
|
156
|
+
abstract class Scope implements QueueRunner {
|
|
157
|
+
// Scopes are to be handled in creation order. This will make sure that parents are
|
|
158
|
+
// handled before their children (as they should), and observes are executed in the
|
|
159
|
+
// order of the source code.
|
|
160
|
+
prio: number = --lastPrio;
|
|
221
161
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
162
|
+
abstract onChange(index: any, newData: DatumType, oldData: DatumType): void;
|
|
163
|
+
abstract queueRun(): void;
|
|
164
|
+
|
|
165
|
+
abstract getLastNode(): Node | undefined;
|
|
166
|
+
abstract getPrecedingNode(): Node | undefined;
|
|
167
|
+
abstract delete(): void;
|
|
225
168
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
// How deep is this scope nested in other scopes; we use this to make sure events
|
|
231
|
-
// at lower depths are handled before events at higher depths.
|
|
232
|
-
public _queueOrder: number,
|
|
233
|
-
) {
|
|
234
|
-
}
|
|
169
|
+
remove() {
|
|
170
|
+
// Remove any nodes
|
|
171
|
+
const lastNode = this.getLastNode();
|
|
172
|
+
if (lastNode) removeNodes(lastNode, this.getPrecedingNode());
|
|
235
173
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
let cur: Scope = this
|
|
239
|
-
let pre: Scope | Node | undefined
|
|
240
|
-
while((pre = cur._precedingSibling) && pre !== stopAt) {
|
|
241
|
-
if (pre instanceof Node) return pre
|
|
242
|
-
let node = pre._findLastNode()
|
|
243
|
-
if (node) return node
|
|
244
|
-
cur = pre
|
|
245
|
-
}
|
|
174
|
+
// Run any cleaners
|
|
175
|
+
this.delete();
|
|
246
176
|
}
|
|
247
177
|
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
else return this._lastChild._findLastNode() || this._lastChild._findPrecedingNode(this._precedingSibling)
|
|
253
|
-
}
|
|
254
|
-
}
|
|
178
|
+
// toString(): string {
|
|
179
|
+
// return `${this.constructor.name}`
|
|
180
|
+
// }
|
|
181
|
+
}
|
|
255
182
|
|
|
256
|
-
|
|
257
|
-
|
|
183
|
+
/**
|
|
184
|
+
* All Scopes that can hold nodes and subscopes, including `SimpleScope` and `OnEachItemScope`
|
|
185
|
+
* but *not* `OnEachScope`, are `ContentScope`s.
|
|
186
|
+
*/
|
|
187
|
+
abstract class ContentScope extends Scope {
|
|
188
|
+
// The list of clean functions to be called when this scope is cleaned. These can
|
|
189
|
+
// be for child scopes, subscriptions as well as `clean(..)` hooks.
|
|
190
|
+
cleaners: Array<{delete: (scope: Scope) => void} | (() => void)>;
|
|
258
191
|
|
|
259
|
-
|
|
260
|
-
|
|
192
|
+
constructor(cleaners: Array<{delete: (scope: Scope) => void} | (() => void)> = []) {
|
|
193
|
+
super();
|
|
194
|
+
this.cleaners = cleaners;
|
|
261
195
|
}
|
|
262
196
|
|
|
263
|
-
|
|
264
|
-
if (this._parentElement) {
|
|
265
|
-
let lastNode: Node | undefined = this._findLastNode()
|
|
266
|
-
if (lastNode) {
|
|
267
|
-
// at least one DOM node to be removed
|
|
268
|
-
|
|
269
|
-
let nextNode: Node | undefined = this._findPrecedingNode()
|
|
270
|
-
nextNode = (nextNode ? nextNode.nextSibling : this._parentElement.firstChild) as Node | undefined
|
|
197
|
+
lastChild: Node | Scope | undefined;
|
|
271
198
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
// Keep removing DOM nodes starting at our first node, until we encounter the last node
|
|
275
|
-
while(true) {
|
|
276
|
-
/* c8 ignore next */
|
|
277
|
-
if (!nextNode) return internalError(1)
|
|
278
|
-
|
|
279
|
-
const node = nextNode
|
|
280
|
-
nextNode = node.nextSibling || undefined
|
|
281
|
-
let onDestroy = onDestroyMap.get(node)
|
|
282
|
-
if (onDestroy && node instanceof Element) {
|
|
283
|
-
if (onDestroy !== true) {
|
|
284
|
-
if (typeof onDestroy === 'function') {
|
|
285
|
-
onDestroy(node)
|
|
286
|
-
} else {
|
|
287
|
-
destroyWithClass(node, onDestroy)
|
|
288
|
-
}
|
|
289
|
-
// This causes the element to be ignored from this function from now on:
|
|
290
|
-
onDestroyMap.set(node, true)
|
|
291
|
-
}
|
|
292
|
-
// Ignore the deleting element
|
|
293
|
-
} else {
|
|
294
|
-
this._parentElement.removeChild(node)
|
|
295
|
-
}
|
|
296
|
-
if (node === lastNode) break
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
199
|
+
// Should be subclassed in most cases..
|
|
200
|
+
redraw() {};
|
|
300
201
|
|
|
301
|
-
|
|
302
|
-
|
|
202
|
+
abstract parentElement: Element;
|
|
203
|
+
|
|
204
|
+
getLastNode(): Node | undefined {
|
|
205
|
+
return findLastNodeInPrevSiblings(this.lastChild);
|
|
303
206
|
}
|
|
304
207
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
208
|
+
/**
|
|
209
|
+
* Call cleaners and make sure the scope is not queued.
|
|
210
|
+
* It is called `delete`, so that the list of cleaners can also contain `Set`s.
|
|
211
|
+
*/
|
|
212
|
+
delete(/* ignore observer argument */) {
|
|
213
|
+
for(let cleaner of this.cleaners) {
|
|
214
|
+
if (typeof cleaner === 'function') cleaner();
|
|
215
|
+
else cleaner.delete(this); // pass in observer argument, in case `cleaner` is a `Set`
|
|
309
216
|
}
|
|
310
|
-
this.
|
|
217
|
+
this.cleaners.length = 0;
|
|
218
|
+
sortedQueue?.remove(this); // This is very fast and O(1) when not queued
|
|
219
|
+
|
|
220
|
+
// To prepare for a redraw or to help GC when we're being removed:
|
|
221
|
+
this.lastChild = undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
queueRun() {
|
|
225
|
+
this.remove();
|
|
226
|
+
|
|
227
|
+
topRedrawScope = this
|
|
228
|
+
this.redraw();
|
|
229
|
+
topRedrawScope = undefined
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
getInsertAfterNode() {
|
|
233
|
+
return this.getLastNode() || this.getPrecedingNode();
|
|
311
234
|
}
|
|
312
235
|
|
|
313
|
-
|
|
314
|
-
queue(this)
|
|
236
|
+
onChange(index: any, newData: DatumType, oldData: DatumType) {
|
|
237
|
+
queue(this);
|
|
315
238
|
}
|
|
316
239
|
|
|
317
|
-
|
|
240
|
+
getChildPrevSibling() {
|
|
241
|
+
return this.lastChild;
|
|
242
|
+
}
|
|
318
243
|
}
|
|
319
244
|
|
|
320
|
-
|
|
245
|
+
|
|
246
|
+
class ChainedScope extends ContentScope {
|
|
247
|
+
// The node or scope right before this scope that has the same `parentElement`.
|
|
248
|
+
public prevSibling: Node | Scope | undefined;
|
|
249
|
+
|
|
321
250
|
constructor(
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
251
|
+
// The parent DOM element we'll add our child nodes to.
|
|
252
|
+
public parentElement: Element,
|
|
253
|
+
// When true, we share our 'cleaners' list with the parent scope.
|
|
254
|
+
useParentCleaners: boolean = false,
|
|
326
255
|
) {
|
|
327
|
-
super(
|
|
328
|
-
if (
|
|
329
|
-
|
|
256
|
+
super(useParentCleaners ? currentScope.cleaners : []);
|
|
257
|
+
if (parentElement === currentScope.parentElement) {
|
|
258
|
+
// If `currentScope` is not actually a ChainedScope, prevSibling will be undefined, as intended
|
|
259
|
+
this.prevSibling = currentScope.getChildPrevSibling();
|
|
260
|
+
currentScope.lastChild = this;
|
|
261
|
+
}
|
|
330
262
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
internalError(14)
|
|
263
|
+
// We're always adding ourselve as a cleaner, in order to run our own cleaners
|
|
264
|
+
// and to remove ourselve from the queue (if we happen to be in there).
|
|
265
|
+
if (!useParentCleaners) currentScope.cleaners.push(this);
|
|
335
266
|
}
|
|
336
|
-
/* c8 ignore stop */
|
|
337
267
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
268
|
+
getPrecedingNode(): Node | undefined {
|
|
269
|
+
return findLastNodeInPrevSiblings(this.prevSibling);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
getChildPrevSibling() {
|
|
273
|
+
return this.lastChild || this.prevSibling;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
341
276
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
277
|
+
/**
|
|
278
|
+
* @internal
|
|
279
|
+
* A `RegularScope` is created with a `render` function that is run initially,
|
|
280
|
+
* and again when any of the `Store`s that this function reads are changed. Any
|
|
281
|
+
* DOM elements that is given a `render` function for its contents has its own scope.
|
|
282
|
+
* The `Scope` manages the position in the DOM tree elements created by `render`
|
|
283
|
+
* are inserted at. Before a rerender, all previously created elements are removed
|
|
284
|
+
* and the `clean` functions for the scope and all sub-scopes are called.
|
|
285
|
+
*/
|
|
286
|
+
class RegularScope extends ChainedScope {
|
|
287
|
+
constructor(
|
|
288
|
+
parentElement: Element,
|
|
289
|
+
// The function that will be reactively called. Elements it creates using `$` are
|
|
290
|
+
// added to the appropriate position within `parentElement`.
|
|
291
|
+
public renderer: () => any,
|
|
292
|
+
) {
|
|
293
|
+
super(parentElement);
|
|
345
294
|
|
|
346
|
-
|
|
295
|
+
// Do the initial run
|
|
296
|
+
this.redraw();
|
|
347
297
|
}
|
|
348
298
|
|
|
349
|
-
|
|
350
|
-
let savedScope = currentScope
|
|
351
|
-
currentScope = this
|
|
299
|
+
redraw() {
|
|
300
|
+
let savedScope = currentScope;
|
|
301
|
+
currentScope = this;
|
|
352
302
|
try {
|
|
353
|
-
this.
|
|
303
|
+
this.renderer();
|
|
354
304
|
} catch(e) {
|
|
355
305
|
// Throw the error async, so the rest of the rendering can continue
|
|
356
|
-
handleError(e, true)
|
|
306
|
+
handleError(e, true);
|
|
357
307
|
}
|
|
358
|
-
currentScope = savedScope
|
|
308
|
+
currentScope = savedScope;
|
|
359
309
|
}
|
|
310
|
+
}
|
|
360
311
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
} else {
|
|
367
|
-
this._update()
|
|
368
|
-
}
|
|
369
|
-
// Add it to our list of cleaners. Even if `childScope` currently has
|
|
370
|
-
// no cleaners, it may get them in a future refresh.
|
|
371
|
-
currentScope!._cleaners.push(this)
|
|
312
|
+
|
|
313
|
+
class RootScope extends ContentScope {
|
|
314
|
+
parentElement = document.body;
|
|
315
|
+
getPrecedingNode(): Node | undefined {
|
|
316
|
+
return undefined;
|
|
372
317
|
}
|
|
373
318
|
}
|
|
374
319
|
|
|
375
|
-
|
|
376
|
-
* This could have been done with a SimpleScope, but then we'd have to draw along an instance of
|
|
377
|
-
* that as well as a renderer function that closes over quite a few variables, which probably
|
|
378
|
-
* wouldn't be great for the performance of this common feature.
|
|
379
|
-
*/
|
|
380
|
-
class SetArgScope extends SimpleScope {
|
|
320
|
+
class MountScope extends ContentScope {
|
|
381
321
|
constructor(
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
private _value: Store,
|
|
322
|
+
// The parent DOM element we'll add our child nodes to
|
|
323
|
+
public parentElement: Element,
|
|
324
|
+
// The function that
|
|
325
|
+
public renderer: () => any,
|
|
387
326
|
) {
|
|
388
|
-
super(
|
|
327
|
+
super();
|
|
328
|
+
this.redraw();
|
|
329
|
+
currentScope.cleaners.push(this)
|
|
389
330
|
}
|
|
390
331
|
|
|
391
|
-
|
|
392
|
-
|
|
332
|
+
redraw() {
|
|
333
|
+
RegularScope.prototype.redraw.call(this);
|
|
393
334
|
}
|
|
394
|
-
}
|
|
395
335
|
|
|
396
|
-
|
|
336
|
+
getPrecedingNode(): Node | undefined {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
397
339
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
340
|
+
delete() {
|
|
341
|
+
// We can't rely on our parent scope to remove all our nodes for us, as our parent
|
|
342
|
+
// probably has a totally different `parentElement`. Therefore, our `delete()` does
|
|
343
|
+
// what `_remove()` does for regular scopes.
|
|
344
|
+
removeNodes(this.getLastNode(), this.getPrecedingNode());
|
|
345
|
+
super.delete();
|
|
401
346
|
}
|
|
402
|
-
}
|
|
403
347
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
if (immediateQueuerRunning) return
|
|
407
|
-
for(let count=0; immediateQueue.size; count++) {
|
|
408
|
-
if (count > 42) {
|
|
409
|
-
immediateQueue.clear()
|
|
410
|
-
throw new Error("Too many recursive updates from immediate-mode observes")
|
|
411
|
-
}
|
|
412
|
-
immediateQueuerRunning = true
|
|
413
|
-
let copy = immediateQueue
|
|
414
|
-
immediateQueue = new Set()
|
|
415
|
-
let savedScope = currentScope
|
|
416
|
-
currentScope = undefined
|
|
417
|
-
try {
|
|
418
|
-
for(const scope of copy) {
|
|
419
|
-
scope._queueRun()
|
|
420
|
-
}
|
|
421
|
-
} finally {
|
|
422
|
-
currentScope = savedScope
|
|
423
|
-
immediateQueuerRunning = false
|
|
424
|
-
}
|
|
348
|
+
remove() {
|
|
349
|
+
this.delete();
|
|
425
350
|
}
|
|
426
351
|
}
|
|
427
352
|
|
|
428
|
-
class IsEmptyObserver implements Observer {
|
|
429
|
-
scope: Scope
|
|
430
|
-
collection: ObsCollection
|
|
431
|
-
count: number
|
|
432
|
-
triggerCount: boolean
|
|
433
353
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
354
|
+
// Remove node and all its preceding siblings (uptil and excluding preNode)
|
|
355
|
+
// from the DOM, using onDestroy if applicable.
|
|
356
|
+
function removeNodes(node: Node | null | undefined, preNode: Node | null | undefined) {
|
|
357
|
+
while(node && node !== preNode) {
|
|
358
|
+
const prevNode: Node | null = node.previousSibling;
|
|
359
|
+
let onDestroy = onDestroyMap.get(node);
|
|
360
|
+
if (onDestroy && node instanceof Element) {
|
|
361
|
+
if (onDestroy !== true) {
|
|
362
|
+
if (typeof onDestroy === 'function') {
|
|
363
|
+
onDestroy(node);
|
|
364
|
+
} else {
|
|
365
|
+
destroyWithClass(node, onDestroy);
|
|
366
|
+
}
|
|
367
|
+
// This causes the element to be ignored from this function from now on:
|
|
368
|
+
onDestroyMap.set(node, true);
|
|
369
|
+
}
|
|
370
|
+
// Ignore the deleting element
|
|
371
|
+
} else {
|
|
372
|
+
(node as Element|Text).remove();
|
|
450
373
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
_clean() {
|
|
454
|
-
this.collection._removeObserver(ANY_INDEX, this)
|
|
374
|
+
node = prevNode;
|
|
455
375
|
}
|
|
456
376
|
}
|
|
457
377
|
|
|
458
|
-
|
|
459
|
-
|
|
378
|
+
// Get a reference to the last node within `sibling` or any of its preceding siblings.
|
|
379
|
+
// If a `Node` is given, that node is returned.
|
|
380
|
+
function findLastNodeInPrevSiblings(sibling: Node | Scope | undefined): Node | undefined {
|
|
381
|
+
if (!sibling || sibling instanceof Node) return sibling;
|
|
382
|
+
return sibling.getLastNode() || sibling.getPrecedingNode();
|
|
383
|
+
}
|
|
460
384
|
|
|
461
|
-
/** The Node we are iterating */
|
|
462
|
-
_collection: ObsCollection
|
|
463
385
|
|
|
464
|
-
|
|
465
|
-
|
|
386
|
+
class ResultScope<T extends DatumType | void> extends ChainedScope {
|
|
387
|
+
public result: ValueRef<T> = optProxy({value: undefined});
|
|
466
388
|
|
|
467
|
-
|
|
468
|
-
|
|
389
|
+
constructor(
|
|
390
|
+
parentElement: Element,
|
|
391
|
+
public renderer: () => T,
|
|
392
|
+
) {
|
|
393
|
+
super(parentElement);
|
|
469
394
|
|
|
470
|
-
|
|
471
|
-
|
|
395
|
+
this.redraw();
|
|
396
|
+
}
|
|
472
397
|
|
|
473
|
-
|
|
474
|
-
|
|
398
|
+
redraw() {
|
|
399
|
+
let savedScope = currentScope;
|
|
400
|
+
currentScope = this;
|
|
401
|
+
try {
|
|
402
|
+
this.result.value = this.renderer();
|
|
403
|
+
} catch(e) {
|
|
404
|
+
// Throw the error async, so the rest of the rendering can continue
|
|
405
|
+
handleError(e, true);
|
|
406
|
+
}
|
|
407
|
+
currentScope = savedScope;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
475
410
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
411
|
+
/**
|
|
412
|
+
* A `Scope` subclass optimized for reactively setting just a single element property
|
|
413
|
+
* based on a proxied reference.
|
|
414
|
+
*/
|
|
479
415
|
|
|
416
|
+
class SetArgScope extends ChainedScope {
|
|
480
417
|
constructor(
|
|
481
|
-
parentElement: Element
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
collection: ObsCollection,
|
|
485
|
-
renderer: (itemStore: Store) => void,
|
|
486
|
-
makeSortKey: (itemStore: Store) => SortKeyType
|
|
418
|
+
parentElement: Element,
|
|
419
|
+
public key: string,
|
|
420
|
+
public target: {value: DatumType},
|
|
487
421
|
) {
|
|
488
|
-
super(parentElement
|
|
489
|
-
this.
|
|
490
|
-
|
|
491
|
-
|
|
422
|
+
super(parentElement);
|
|
423
|
+
this.redraw();
|
|
424
|
+
}
|
|
425
|
+
redraw() {
|
|
426
|
+
let savedScope = currentScope;
|
|
427
|
+
currentScope = this;
|
|
428
|
+
applyArg(this.key, this.target.value)
|
|
429
|
+
currentScope = savedScope;
|
|
492
430
|
}
|
|
431
|
+
}
|
|
493
432
|
|
|
494
|
-
// toString(): string {
|
|
495
|
-
// return `OnEachScope(collection=${this.collection})`
|
|
496
|
-
// }
|
|
497
433
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
434
|
+
let immediateQueue: ReverseSortedSet<Scope> = new ReverseSortedSet('prio');
|
|
435
|
+
|
|
436
|
+
class ImmediateScope extends RegularScope {
|
|
437
|
+
onChange(index: any, newData: DatumType, oldData: DatumType) {
|
|
438
|
+
immediateQueue.add(this);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
let immediateQueueRunning = false;
|
|
443
|
+
function runImmediateQueue() {
|
|
444
|
+
for(let count=0; !immediateQueue.isEmpty() && !immediateQueueRunning; count++) {
|
|
445
|
+
if (count > 42) {
|
|
446
|
+
immediateQueue.clear();
|
|
447
|
+
throw new Error("Too many immediate-mode recursive updates");
|
|
448
|
+
}
|
|
449
|
+
immediateQueueRunning = true;
|
|
450
|
+
let copy = immediateQueue;
|
|
451
|
+
immediateQueue = new ReverseSortedSet('prio');
|
|
452
|
+
try {
|
|
453
|
+
for(const scope of copy) {
|
|
454
|
+
// On exception, the exception will be bubbled up to the call site, discarding any
|
|
455
|
+
// remaining immediate scopes from the queue. This behavior is perhaps debatable,
|
|
456
|
+
// but getting a synchronous exception at the call site can be very helpful.
|
|
457
|
+
scope.queueRun();
|
|
512
458
|
}
|
|
459
|
+
} finally {
|
|
460
|
+
immediateQueueRunning = false;
|
|
513
461
|
}
|
|
514
462
|
}
|
|
463
|
+
}
|
|
515
464
|
|
|
516
|
-
_queueRun() {
|
|
517
|
-
if (this._isDead) return
|
|
518
465
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
})
|
|
466
|
+
/** @internal */
|
|
467
|
+
class OnEachScope extends Scope {
|
|
468
|
+
parentElement: Element = currentScope.parentElement;
|
|
469
|
+
prevSibling: Node | Scope | undefined;
|
|
524
470
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
471
|
+
/** The data structure we are iterating */
|
|
472
|
+
target: TargetType;
|
|
473
|
+
|
|
474
|
+
/** All item scopes, by array index or object key. This is used for removing an item scope when its value
|
|
475
|
+
* disappears, and calling all subscope cleaners. */
|
|
476
|
+
byIndex: Map<any,OnEachItemScope> = new Map();
|
|
531
477
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
this._collection._observers.delete(this)
|
|
535
|
-
for (const [index, scope] of this._byIndex) {
|
|
536
|
-
scope._clean()
|
|
537
|
-
}
|
|
478
|
+
/** The reverse-ordered list of item scopes, not including those for which makeSortKey returned undefined. */
|
|
479
|
+
sortedSet: ReverseSortedSet<OnEachItemScope> = new ReverseSortedSet('sortKey');
|
|
538
480
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
481
|
+
/** Indexes that have been created/removed and need to be handled in the next `queueRun`. */
|
|
482
|
+
changedIndexes: Set<any> = new Set();
|
|
483
|
+
|
|
484
|
+
constructor(
|
|
485
|
+
proxy: TargetType,
|
|
486
|
+
/** A function that renders an item */
|
|
487
|
+
public renderer: (value: DatumType, key: any, ) => void,
|
|
488
|
+
/** A function returning a number/string/array that defines the position of an item */
|
|
489
|
+
public makeSortKey?: (value: DatumType, key: any) => SortKeyType,
|
|
490
|
+
) {
|
|
491
|
+
super();
|
|
492
|
+
const target: TargetType = this.target = (proxy as any)[TARGET_SYMBOL] || proxy;
|
|
543
493
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
let parentScope = currentScope
|
|
494
|
+
subscribe(target, ANY_SYMBOL, this);
|
|
495
|
+
this.prevSibling = currentScope.getChildPrevSibling();
|
|
496
|
+
currentScope.lastChild = this;
|
|
548
497
|
|
|
549
|
-
|
|
498
|
+
currentScope.cleaners.push(this);
|
|
550
499
|
|
|
551
|
-
|
|
500
|
+
// Do _addChild() calls for initial items
|
|
501
|
+
if (target instanceof Array) {
|
|
502
|
+
for(let i=0; i<target.length; i++) {
|
|
503
|
+
if (target[i]!==undefined) {
|
|
504
|
+
new OnEachItemScope(this, i, false);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
for(const key in target) {
|
|
509
|
+
if (target[key] !== undefined) {
|
|
510
|
+
new OnEachItemScope(this, key, false);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
552
514
|
}
|
|
553
515
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
this._byIndex.set(itemIndex, scope)
|
|
557
|
-
scope._update()
|
|
558
|
-
// We're not adding a cleaner here, as we'll be calling them from our _clean function
|
|
516
|
+
getPrecedingNode(): Node | undefined {
|
|
517
|
+
return findLastNodeInPrevSiblings(this.prevSibling);
|
|
559
518
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
if (!scope) return internalError(6)
|
|
565
|
-
scope._remove()
|
|
566
|
-
this._byIndex.delete(itemIndex)
|
|
567
|
-
this._removeFromPosition(scope)
|
|
519
|
+
|
|
520
|
+
onChange(index: any, newData: DatumType, oldData: DatumType) {
|
|
521
|
+
if (!(this.target instanceof Array) || typeof index === 'number') this.changedIndexes.add(index);
|
|
522
|
+
queue(this);
|
|
568
523
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
let
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
while(min<max) {
|
|
580
|
-
let mid = (min+max)>>1
|
|
581
|
-
if (items[mid]._sortStr < sortStr) {
|
|
582
|
-
min = mid+1
|
|
524
|
+
|
|
525
|
+
queueRun() {
|
|
526
|
+
let indexes = this.changedIndexes;
|
|
527
|
+
this.changedIndexes = new Set();
|
|
528
|
+
for(let index of indexes) {
|
|
529
|
+
const oldScope = this.byIndex.get(index);
|
|
530
|
+
if (oldScope) oldScope.remove();
|
|
531
|
+
|
|
532
|
+
if ((this.target as any)[index] === undefined) {
|
|
533
|
+
this.byIndex.delete(index);
|
|
583
534
|
} else {
|
|
584
|
-
|
|
535
|
+
new OnEachItemScope(this, index, true);
|
|
585
536
|
}
|
|
586
537
|
}
|
|
587
|
-
|
|
538
|
+
topRedrawScope = undefined;
|
|
588
539
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
this.
|
|
593
|
-
|
|
594
|
-
// Based on the position in the list, set the precedingSibling for the new Scope
|
|
595
|
-
// and for the next sibling.
|
|
596
|
-
let nextSibling: OnEachItemScope = this._byPosition[pos+1]
|
|
597
|
-
if (nextSibling) {
|
|
598
|
-
child._precedingSibling = nextSibling._precedingSibling
|
|
599
|
-
nextSibling._precedingSibling = child
|
|
600
|
-
} else {
|
|
601
|
-
child._precedingSibling = this._lastChild || this._precedingSibling
|
|
602
|
-
this._lastChild = child
|
|
540
|
+
|
|
541
|
+
delete() {
|
|
542
|
+
// Propagate to all our subscopes
|
|
543
|
+
for (const scope of this.byIndex.values()) {
|
|
544
|
+
scope.delete();
|
|
603
545
|
}
|
|
546
|
+
|
|
547
|
+
// Help garbage collection:
|
|
548
|
+
this.byIndex.clear();
|
|
549
|
+
setTimeout(() => {
|
|
550
|
+
// Unsure if this is a good idea. It takes time, but presumably makes things a lot easier for GC...
|
|
551
|
+
this.sortedSet.clear();
|
|
552
|
+
}, 1);
|
|
604
553
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
if (this._byPosition[pos] === child) {
|
|
611
|
-
// Yep, this is the right scope
|
|
612
|
-
this._byPosition.splice(pos, 1)
|
|
613
|
-
if (pos < this._byPosition.length) {
|
|
614
|
-
let nextSibling: Scope | undefined = this._byPosition[pos] as (Scope | undefined)
|
|
615
|
-
/* c8 ignore next */
|
|
616
|
-
if (!nextSibling) return internalError(8)
|
|
617
|
-
/* c8 ignore next */
|
|
618
|
-
if (nextSibling._precedingSibling !== child) return internalError(13)
|
|
619
|
-
nextSibling._precedingSibling = child._precedingSibling
|
|
620
|
-
} else {
|
|
621
|
-
/* c8 ignore next */
|
|
622
|
-
if (child !== this._lastChild) return internalError(12)
|
|
623
|
-
this._lastChild = child._precedingSibling === this._precedingSibling ? undefined : child._precedingSibling
|
|
624
|
-
}
|
|
625
|
-
return
|
|
626
|
-
}
|
|
627
|
-
// There may be another Scope with the same sortStr
|
|
628
|
-
/* c8 ignore next */
|
|
629
|
-
if (++pos >= this._byPosition.length || this._byPosition[pos]._sortStr !== child._sortStr) return internalError(5)
|
|
554
|
+
|
|
555
|
+
getLastNode(): Node | undefined {
|
|
556
|
+
for(let scope of this.sortedSet) { // Iterates starting at last child scope.
|
|
557
|
+
const node = scope.getActualLastNode();
|
|
558
|
+
if (node) return node;
|
|
630
559
|
}
|
|
631
560
|
}
|
|
632
561
|
}
|
|
633
562
|
|
|
634
563
|
/** @internal */
|
|
635
|
-
class OnEachItemScope extends
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
564
|
+
class OnEachItemScope extends ContentScope {
|
|
565
|
+
sortKey: string | number | undefined; // When undefined, this scope is currently not showing in the list
|
|
566
|
+
public parentElement: Element;
|
|
567
|
+
|
|
640
568
|
constructor(
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
parent: OnEachScope,
|
|
645
|
-
itemIndex: any
|
|
569
|
+
public parent: OnEachScope,
|
|
570
|
+
public itemIndex: any,
|
|
571
|
+
topRedraw: boolean,
|
|
646
572
|
) {
|
|
647
|
-
super(
|
|
648
|
-
this.
|
|
649
|
-
|
|
573
|
+
super();
|
|
574
|
+
this.parentElement = parent.parentElement;
|
|
575
|
+
|
|
576
|
+
this.parent.byIndex.set(this.itemIndex, this);
|
|
577
|
+
|
|
578
|
+
// Okay, this is hacky. In case our first (actual) child is a ChainedScope, we won't be able
|
|
579
|
+
// to provide it with a reliable prevSibling. Therefore, we'll pretend to be that sibling,
|
|
580
|
+
// doing what's need for this case in `getLastNode`.
|
|
581
|
+
// For performance, we prefer not having to create additional 'fake sibling' objects for each item.
|
|
582
|
+
this.lastChild = this;
|
|
583
|
+
|
|
584
|
+
// Don't register to be cleaned by parent scope, as the OnEachScope will manage this for us (for efficiency)
|
|
585
|
+
|
|
586
|
+
if (topRedraw) topRedrawScope = this;
|
|
587
|
+
this.redraw();
|
|
650
588
|
}
|
|
651
589
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
590
|
+
getPrecedingNode(): Node | undefined {
|
|
591
|
+
// As apparently we're interested in the node insert position, we'll need to become part
|
|
592
|
+
// of the sortedSet now (if we weren't already).
|
|
593
|
+
// This will do nothing and barely take any time of `this` is already part of the set:
|
|
594
|
+
this.parent.sortedSet.add(this);
|
|
595
|
+
|
|
596
|
+
const preScope = this.parent.sortedSet.prev(this);
|
|
597
|
+
// As preScope should have inserted itself as its first child, this should
|
|
598
|
+
// recursively call getPrecedingNode() on preScope in case it doesn't
|
|
599
|
+
// have any actual nodes as children yet.
|
|
600
|
+
if (preScope) return findLastNodeInPrevSiblings(preScope.lastChild);
|
|
601
|
+
return this.parent.getPrecedingNode();
|
|
602
|
+
}
|
|
655
603
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
604
|
+
getLastNode(): Node | undefined {
|
|
605
|
+
// Hack! As explain in the constructor, this getLastNode method actually
|
|
606
|
+
// does not return the last node, but the preceding one.
|
|
607
|
+
return this.getPrecedingNode();
|
|
608
|
+
}
|
|
659
609
|
|
|
660
|
-
|
|
661
|
-
this.
|
|
662
|
-
this._isDead = false
|
|
610
|
+
getActualLastNode(): Node | undefined {
|
|
611
|
+
let child = this.lastChild;
|
|
663
612
|
|
|
664
|
-
this
|
|
613
|
+
while(child && child !== this) {
|
|
614
|
+
if (child instanceof Node) return child;
|
|
615
|
+
const node = child.getLastNode();
|
|
616
|
+
if (node) return node;
|
|
617
|
+
child = child.getPrecedingNode();
|
|
618
|
+
}
|
|
665
619
|
}
|
|
666
620
|
|
|
667
|
-
|
|
621
|
+
queueRun() {
|
|
622
|
+
/* c8 ignore next */
|
|
623
|
+
if (currentScope !== ROOT_SCOPE) internalError(4);
|
|
624
|
+
|
|
625
|
+
// We're not calling `remove` here, as we don't want to remove ourselves from
|
|
626
|
+
// the sorted set. `redraw` will take care of that, if needed.
|
|
627
|
+
// Also, we can't use `getLastNode` here, as we've hacked it to return the
|
|
628
|
+
// preceding node instead.
|
|
629
|
+
if (this.sortKey !== undefined) {
|
|
630
|
+
const lastNode = this.getActualLastNode();
|
|
631
|
+
if (lastNode) removeNodes(lastNode, this.getPrecedingNode());
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
this.delete();
|
|
635
|
+
this.lastChild = this; // apply the hack (see constructor) again
|
|
636
|
+
|
|
637
|
+
topRedrawScope = this;
|
|
638
|
+
this.redraw();
|
|
639
|
+
topRedrawScope = undefined;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
redraw() {
|
|
668
643
|
// Have the makeSortKey function return an ordering int/string/array.
|
|
669
|
-
// Since makeSortKey may get() the Store, we'll need to set currentScope first.
|
|
670
|
-
let savedScope = currentScope
|
|
671
|
-
currentScope = this
|
|
672
644
|
|
|
673
|
-
|
|
645
|
+
// Note that we're NOT subscribing on target[itemIndex], as the OnEachScope uses
|
|
646
|
+
// a wildcard subscription to delete/recreate any scopes when that changes.
|
|
647
|
+
// We ARE creating a proxy around the value though (in case its an object/array),
|
|
648
|
+
// so we'll have our own scope subscribe to changes on that.
|
|
649
|
+
const value: DatumType = optProxy((this.parent.target as any)[this.itemIndex]);
|
|
674
650
|
|
|
675
|
-
|
|
651
|
+
// Since makeSortKey may get() the Store, we'll need to set currentScope first.
|
|
652
|
+
let savedScope = currentScope;
|
|
653
|
+
currentScope = this;
|
|
654
|
+
|
|
655
|
+
let sortKey : undefined | string | number;
|
|
676
656
|
try {
|
|
677
|
-
|
|
657
|
+
if (this.parent.makeSortKey) {
|
|
658
|
+
let rawSortKey = this.parent.makeSortKey(value, this.itemIndex);
|
|
659
|
+
if (rawSortKey != null) sortKey = rawSortKey instanceof Array ? rawSortKey.map(partToStr).join('') : rawSortKey;
|
|
660
|
+
} else {
|
|
661
|
+
sortKey = this.itemIndex;
|
|
662
|
+
}
|
|
663
|
+
if (typeof sortKey === 'number') sortKey = partToStr(sortKey);
|
|
664
|
+
|
|
665
|
+
if (this.sortKey !== sortKey) {
|
|
666
|
+
// If the sortKey is changed, make sure `this` is removed from the
|
|
667
|
+
// set before setting the new sortKey to it.
|
|
668
|
+
this.parent.sortedSet.remove(this); // Very fast if `this` is not in the set
|
|
669
|
+
this.sortKey = sortKey;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// We're not adding `this` to the `sortedSet` (yet), as that may not be needed,
|
|
673
|
+
// in case no nodes are created. We'll do it just-in-time in `getPrecedingNode`.
|
|
674
|
+
|
|
675
|
+
if (sortKey != null) this.parent.renderer(value, this.itemIndex);
|
|
678
676
|
} catch(e) {
|
|
679
|
-
handleError(e,
|
|
677
|
+
handleError(e, sortKey!=null);
|
|
680
678
|
}
|
|
681
679
|
|
|
682
|
-
|
|
683
|
-
|
|
680
|
+
currentScope = savedScope;
|
|
681
|
+
}
|
|
684
682
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
683
|
+
getInsertAfterNode() {
|
|
684
|
+
if (this.sortKey == null) internalError(1);
|
|
685
|
+
// Due to the `this` being the first child for `this` hack, this will look
|
|
686
|
+
// for the preceding node as well, if we don't have nodes ourselves.
|
|
687
|
+
return findLastNodeInPrevSiblings(this.lastChild);
|
|
688
|
+
}
|
|
688
689
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
}
|
|
690
|
+
remove() {
|
|
691
|
+
// We can't use getLastNode here, as we've hacked it to return the preceding
|
|
692
|
+
// node instead.
|
|
693
|
+
if (this.sortKey !== undefined) {
|
|
694
|
+
const lastNode = this.getActualLastNode();
|
|
695
|
+
if (lastNode) removeNodes(lastNode, this.getPrecedingNode());
|
|
696
|
+
|
|
697
|
+
this.parent.sortedSet.remove(this);
|
|
698
|
+
this.sortKey = undefined;
|
|
699
699
|
}
|
|
700
700
|
|
|
701
|
-
|
|
701
|
+
this.delete();
|
|
702
702
|
}
|
|
703
703
|
}
|
|
704
704
|
|
|
705
|
+
function addNode(node: Node) {
|
|
706
|
+
const parentEl = currentScope.parentElement;
|
|
707
|
+
const prevNode = currentScope.getInsertAfterNode();
|
|
708
|
+
parentEl.insertBefore(node, prevNode ? prevNode.nextSibling : parentEl.firstChild);
|
|
709
|
+
currentScope.lastChild = node;
|
|
710
|
+
}
|
|
711
|
+
|
|
705
712
|
|
|
706
713
|
/**
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
714
|
+
* This global is set during the execution of a `Scope.render`. It is used by
|
|
715
|
+
* functions like `$` and `clean`.
|
|
716
|
+
*/
|
|
717
|
+
const ROOT_SCOPE = new RootScope();
|
|
718
|
+
let currentScope: ContentScope = ROOT_SCOPE;
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* A special Node observer index to subscribe to any value in the map changing.
|
|
722
|
+
*/
|
|
723
|
+
const ANY_SYMBOL = Symbol('any');
|
|
711
724
|
|
|
712
725
|
/**
|
|
713
|
-
*
|
|
726
|
+
* When our proxy objects need to lookup `obj[TARGET_SYMBOL]` it returns its
|
|
727
|
+
* target, to be used in our wrapped methods.
|
|
714
728
|
*/
|
|
715
|
-
const
|
|
729
|
+
const TARGET_SYMBOL = Symbol('target');
|
|
716
730
|
|
|
717
731
|
|
|
718
|
-
|
|
732
|
+
const subscribers = new WeakMap<TargetType, Map<any, Set<Scope | ((index: any, newData: DatumType, oldData: DatumType) => void)>>>;
|
|
733
|
+
let peeking = 0; // When > 0, we're not subscribing to any changes
|
|
719
734
|
|
|
735
|
+
function subscribe(target: any, index: symbol|string|number, observer: Scope | ((index: any, newData: DatumType, oldData: DatumType) => void) = currentScope) {
|
|
736
|
+
if (observer === ROOT_SCOPE || peeking) return;
|
|
720
737
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
_observers: Map<any, Set<Observer>> = new Map()
|
|
738
|
+
let byTarget = subscribers.get(target);
|
|
739
|
+
if (!byTarget) subscribers.set(target, byTarget = new Map());
|
|
724
740
|
|
|
725
|
-
//
|
|
726
|
-
|
|
727
|
-
// }
|
|
741
|
+
// No need to subscribe to specific keys if we're already subscribed to ANY
|
|
742
|
+
if (index !== ANY_SYMBOL && byTarget.get(ANY_SYMBOL)?.has(observer)) return;
|
|
728
743
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
if (obsSet.has(observer)) return false
|
|
734
|
-
obsSet.add(observer)
|
|
735
|
-
} else {
|
|
736
|
-
this._observers.set(index, new Set([observer]))
|
|
737
|
-
}
|
|
738
|
-
return true
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
_removeObserver(index: any, observer: Observer) {
|
|
742
|
-
let obsSet = <Set<Observer>>this._observers.get(index)
|
|
743
|
-
obsSet.delete(observer)
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
emitChange(index: any, newData: DatumType, oldData: DatumType) {
|
|
747
|
-
let obsSet = this._observers.get(index)
|
|
748
|
-
if (obsSet) obsSet.forEach(observer => observer._onChange(index, newData, oldData))
|
|
749
|
-
obsSet = this._observers.get(ANY_INDEX)
|
|
750
|
-
if (obsSet) obsSet.forEach(observer => observer._onChange(index, newData, oldData))
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
_clean(observer: Observer) {
|
|
754
|
-
this._removeObserver(ANY_INDEX, observer)
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
_setIndex(index: any, newValue: any, deleteMissing: boolean): void {
|
|
758
|
-
const curData = this.rawGet(index)
|
|
759
|
-
|
|
760
|
-
if (!(curData instanceof ObsCollection) || newValue instanceof Store || !curData._merge(newValue, deleteMissing)) {
|
|
761
|
-
let newData = valueToData(newValue)
|
|
762
|
-
if (newData !== curData) {
|
|
763
|
-
this.rawSet(index, newData)
|
|
764
|
-
this.emitChange(index, newData, curData)
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
}
|
|
744
|
+
let byIndex = byTarget.get(index);
|
|
745
|
+
if (!byIndex) byTarget.set(index, byIndex = new Set());
|
|
746
|
+
|
|
747
|
+
if (byIndex.has(observer)) return;
|
|
768
748
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
749
|
+
byIndex.add(observer);
|
|
750
|
+
|
|
751
|
+
if (observer === currentScope) {
|
|
752
|
+
currentScope.cleaners.push(byIndex);
|
|
753
|
+
} else {
|
|
754
|
+
currentScope.cleaners.push(function() {
|
|
755
|
+
byIndex.delete(observer);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
777
758
|
}
|
|
778
759
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
_data: Array<DatumType> = []
|
|
760
|
+
export function onEach<T>(target: Array<undefined|T>, render: (value: T, index: number) => void, makeKey?: (value: T, key: any) => SortKeyType): void;
|
|
761
|
+
export function onEach<K extends string|number|symbol,T>(target: Record<K,undefined|T>, render: (value: T, index: K) => void, makeKey?: (value: T, key: K) => SortKeyType): void;
|
|
782
762
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
763
|
+
/**
|
|
764
|
+
* Reactively iterates over the items of an observable array or object, optionally rendering content for each item.
|
|
765
|
+
*
|
|
766
|
+
* Automatically updates when items are added, removed, or modified.
|
|
767
|
+
*
|
|
768
|
+
* @param target The observable array or object to iterate over. Values that are `undefined` are skipped.
|
|
769
|
+
* @param render A function called for each item in the array. It receives the item's (observable) value and its index/key. Any DOM elements created within this function will be associated with the item, placed at the right spot in the DOM, and cleaned up when redrawing/removing the item.
|
|
770
|
+
* @param makeKey An optional function to generate a sort key for each item. This controls the order in which items are rendered in the DOM. If omitted, items are rendered in array index order. The returned key can be a number, string, or an array of numbers/strings for composite sorting. Use {@link invertString} on string keys for descending order. Returning `null` or `undefined` from `makeKey` will prevent the item from being rendered.
|
|
771
|
+
*
|
|
772
|
+
* @example Iterating an array
|
|
773
|
+
* ```typescript
|
|
774
|
+
* const items = proxy(['apple', 'banana', 'cherry']);
|
|
775
|
+
*
|
|
776
|
+
* // Basic iteration
|
|
777
|
+
* onEach(items, (item, index) => $(`li:${item} (#${index})`));
|
|
778
|
+
*
|
|
779
|
+
* // Add a new item - the list updates automatically
|
|
780
|
+
* setTimeout(() => items.push('durian'), 2000);
|
|
781
|
+
* // Same for updates and deletes
|
|
782
|
+
* setTimeout(() => items[1] = 'berry', 4000);
|
|
783
|
+
* setTimeout(() => delete items[2], 6000);
|
|
784
|
+
* ```
|
|
785
|
+
*
|
|
786
|
+
* @example Iterating an array with custom ordering
|
|
787
|
+
* ```typescript
|
|
788
|
+
* const users = proxy([
|
|
789
|
+
* { id: 3, group: 1, name: 'Charlie' },
|
|
790
|
+
* { id: 1, group: 1, name: 'Alice' },
|
|
791
|
+
* { id: 2, group: 2, name: 'Bob' },
|
|
792
|
+
* ]);
|
|
793
|
+
*
|
|
794
|
+
* // Sort by name alphabetically
|
|
795
|
+
* onEach(users, (user) => {
|
|
796
|
+
* $(`p:${user.name} (id=${user.id})`);
|
|
797
|
+
* }, (user) => [user.group, user.name]); // Sort by group, and within each group sort by name
|
|
798
|
+
* ```
|
|
799
|
+
*
|
|
800
|
+
* @example Iterating an object
|
|
801
|
+
* ```javascript
|
|
802
|
+
* const config = proxy({ theme: 'dark', fontSize: 14, showTips: true });
|
|
803
|
+
*
|
|
804
|
+
* // Display configuration options
|
|
805
|
+
* $('dl', () => {
|
|
806
|
+
* onEach(config, (value, key) => {
|
|
807
|
+
* if (key === 'showTips') return; // Don't render this one
|
|
808
|
+
* $('dt:'+key);
|
|
809
|
+
* $('dd:'+value);
|
|
810
|
+
* });
|
|
811
|
+
* });
|
|
812
|
+
*
|
|
813
|
+
* // Change a value - the display updates automatically
|
|
814
|
+
* setTimeout(() => config.fontSize = 16, 2000);
|
|
815
|
+
* ```
|
|
816
|
+
* @see {@link invertString} To easily create keys for reverse sorting.
|
|
817
|
+
*/
|
|
818
|
+
export function onEach(target: TargetType, render: (value: DatumType, index: any) => void, makeKey?: (value: DatumType, key: any) => SortKeyType): void {
|
|
819
|
+
if (!target || typeof target !== 'object') throw new Error('onEach requires an object');
|
|
820
|
+
target = (target as any)[TARGET_SYMBOL] || target;
|
|
815
821
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
return false
|
|
819
|
-
}
|
|
820
|
-
// newValue is an array
|
|
822
|
+
new OnEachScope(target, render, makeKey);
|
|
823
|
+
}
|
|
821
824
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
+
function isObjEmpty(obj: object): boolean {
|
|
826
|
+
for(let k in obj) return false;
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
825
829
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
830
|
+
/**
|
|
831
|
+
* Reactively checks if an observable array or object is empty.
|
|
832
|
+
*
|
|
833
|
+
* This function not only returns the current emptiness state but also establishes
|
|
834
|
+
* a reactive dependency. If the emptiness state of the `proxied` object or array
|
|
835
|
+
* changes later (e.g., an item is added to an empty array, or the last property
|
|
836
|
+
* is deleted from an object), the scope that called `isEmpty` will be automatically
|
|
837
|
+
* scheduled for re-evaluation.
|
|
838
|
+
*
|
|
839
|
+
* @param proxied The observable array or object (obtained via `observe()`) to check.
|
|
840
|
+
* @returns `true` if the array has length 0 or the object has no own enumerable properties, `false` otherwise.
|
|
841
|
+
*
|
|
842
|
+
* @example
|
|
843
|
+
* ```typescript
|
|
844
|
+
* const items = proxy([]);
|
|
845
|
+
*
|
|
846
|
+
* // Reactively display a message if the items array is empty
|
|
847
|
+
* $('div', () => {
|
|
848
|
+
* if (isEmpty(items)) {
|
|
849
|
+
* $('p', 'i:No items yet!');
|
|
850
|
+
* } else {
|
|
851
|
+
* onEach(items, item=>$('p:'+item));
|
|
852
|
+
* }
|
|
853
|
+
* });
|
|
854
|
+
*
|
|
855
|
+
* // Adding an item will automatically remove the "No items yet!" message
|
|
856
|
+
* setInterval(() => {
|
|
857
|
+
* if (!items.length || Math.random()>0.5) items.push('Item');
|
|
858
|
+
* else items.length = 0;
|
|
859
|
+
* }, 1000)
|
|
860
|
+
* ```
|
|
861
|
+
*/
|
|
862
|
+
export function isEmpty(proxied: TargetType): boolean {
|
|
863
|
+
const target = (proxied as any)[TARGET_SYMBOL] || proxied;
|
|
864
|
+
const scope = currentScope;
|
|
865
|
+
|
|
866
|
+
if (target instanceof Array) {
|
|
867
|
+
subscribe(target, 'length', function(index: any, newData: DatumType, oldData: DatumType) {
|
|
868
|
+
if (!newData !== !oldData) queue(scope);
|
|
869
|
+
});
|
|
870
|
+
return !target.length;
|
|
871
|
+
} else {
|
|
872
|
+
const result = isObjEmpty(target);
|
|
873
|
+
subscribe(target, ANY_SYMBOL, function(index: any, newData: DatumType, oldData: DatumType) {
|
|
874
|
+
if (result ? oldData===undefined : newData===undefined) queue(scope);
|
|
875
|
+
});
|
|
876
|
+
return result;
|
|
839
877
|
}
|
|
878
|
+
}
|
|
840
879
|
|
|
880
|
+
/** @private */
|
|
881
|
+
export interface ValueRef<T> {
|
|
882
|
+
value: T;
|
|
883
|
+
}
|
|
841
884
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
885
|
+
/**
|
|
886
|
+
* Reactively counts the number of properties in an objects.
|
|
887
|
+
*
|
|
888
|
+
* @param proxied The observable object to count. In case an `array` is passed in, a {@link ref} to its `.length` will be returned.
|
|
889
|
+
* @returns an observable object for which the `value` property reflects the number of properties in `proxied` with a value other than `undefined`.
|
|
890
|
+
*
|
|
891
|
+
* @example
|
|
892
|
+
* ```typescript
|
|
893
|
+
* const items = proxy({x: 3, y: 7} as any);
|
|
894
|
+
* const cnt = count(items);
|
|
895
|
+
*
|
|
896
|
+
* // Create a DOM text node for the count:
|
|
897
|
+
* $('div', {text: cnt});
|
|
898
|
+
* // <div>2</div>
|
|
849
899
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
900
|
+
* // Or we can use it in an {@link observe} function:
|
|
901
|
+
* observe(() => console.log("The count is now", cnt.value));
|
|
902
|
+
* // The count is now 2
|
|
903
|
+
*
|
|
904
|
+
* // Adding/removing items will update the count
|
|
905
|
+
* items.z = 12;
|
|
906
|
+
* // Asynchronously, after 0ms:
|
|
907
|
+
* // <div>3</div>
|
|
908
|
+
* // The count is now 3
|
|
909
|
+
* ```
|
|
910
|
+
*/
|
|
911
|
+
export function count(proxied: TargetType): ValueRef<number> {
|
|
912
|
+
if (proxied instanceof Array) return ref(proxied, 'length');
|
|
860
913
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
914
|
+
const target = (proxied as any)[TARGET_SYMBOL] || proxied;
|
|
915
|
+
let cnt = 0;
|
|
916
|
+
for(let k in target) if (target[k] !== undefined) cnt++;
|
|
917
|
+
|
|
918
|
+
const result = proxy(cnt);
|
|
919
|
+
subscribe(target, ANY_SYMBOL, function(index: any, newData: DatumType, oldData: DatumType) {
|
|
920
|
+
if (oldData===newData) {}
|
|
921
|
+
else if (oldData===undefined) result.value = ++cnt;
|
|
922
|
+
else if (newData===undefined) result.value = --cnt;
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
return result;
|
|
864
926
|
}
|
|
865
927
|
|
|
866
928
|
/** @internal */
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
929
|
+
export function defaultEmitHandler(target: TargetType, index: string|symbol|number, newData: DatumType, oldData: DatumType) {
|
|
930
|
+
// We're triggering for values changing from undefined to undefined, as this *may*
|
|
931
|
+
// indicate a change from or to `[empty]` (such as `[,1][0]`).
|
|
932
|
+
if (newData === oldData && newData !== undefined) return;
|
|
933
|
+
|
|
934
|
+
const byTarget = subscribers.get(target);
|
|
935
|
+
if (byTarget===undefined) return;
|
|
936
|
+
|
|
937
|
+
for(const what of [index, ANY_SYMBOL]) {
|
|
938
|
+
let byIndex = byTarget.get(what);
|
|
939
|
+
if (byIndex) {
|
|
940
|
+
for(let observer of byIndex) {
|
|
941
|
+
if (typeof observer === 'function') observer(index, newData, oldData);
|
|
942
|
+
else observer.onChange(index, newData, oldData)
|
|
878
943
|
}
|
|
879
944
|
}
|
|
880
|
-
let result: Map<any,any> = new Map()
|
|
881
|
-
this.data.forEach((v: any, k: any) => {
|
|
882
|
-
result.set(k, (v instanceof ObsCollection) ? (depth ? v._getRecursive(depth-1) : new Store(this, k)) : v)
|
|
883
|
-
})
|
|
884
|
-
return result
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
rawGet(index: any): DatumType {
|
|
888
|
-
return this.data.get(index)
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
rawSet(index: any, newData: DatumType): void {
|
|
892
|
-
if (newData===undefined) {
|
|
893
|
-
this.data.delete(index)
|
|
894
|
-
} else {
|
|
895
|
-
this.data.set(index, newData)
|
|
896
|
-
}
|
|
897
945
|
}
|
|
946
|
+
}
|
|
947
|
+
let emit = defaultEmitHandler;
|
|
898
948
|
|
|
899
|
-
_merge(newValue: any, deleteMissing: boolean): boolean {
|
|
900
|
-
if (!(newValue instanceof Map)) {
|
|
901
|
-
return false
|
|
902
|
-
}
|
|
903
949
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
950
|
+
const objectHandler: ProxyHandler<any> = {
|
|
951
|
+
get(target: any, prop: any) {
|
|
952
|
+
if (prop===TARGET_SYMBOL) return target;
|
|
953
|
+
subscribe(target, prop);
|
|
954
|
+
return optProxy(target[prop]);
|
|
955
|
+
},
|
|
956
|
+
set(target: any, prop: any, newData: any) {
|
|
957
|
+
// Make sure newData is unproxied
|
|
958
|
+
if (typeof newData === 'object' && newData) newData = (newData as any)[TARGET_SYMBOL] || newData;
|
|
959
|
+
const oldData = target[prop];
|
|
960
|
+
if (newData !== oldData) {
|
|
961
|
+
target[prop] = newData;
|
|
962
|
+
emit(target, prop, newData, oldData);
|
|
963
|
+
runImmediateQueue();
|
|
913
964
|
}
|
|
914
|
-
return true
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
965
|
+
return true;
|
|
966
|
+
},
|
|
967
|
+
deleteProperty(target: any, prop: any) {
|
|
968
|
+
const old = target[prop];
|
|
969
|
+
delete target[prop];
|
|
970
|
+
emit(target, prop, undefined, old);
|
|
971
|
+
runImmediateQueue();
|
|
972
|
+
return true;
|
|
973
|
+
},
|
|
974
|
+
has(target: any, prop: any) {
|
|
975
|
+
const result = prop in target;
|
|
976
|
+
subscribe(target, prop);
|
|
977
|
+
return result;
|
|
978
|
+
},
|
|
979
|
+
ownKeys(target: any) {
|
|
980
|
+
subscribe(target, ANY_SYMBOL);
|
|
981
|
+
return Reflect.ownKeys(target);
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
function arraySet(target: any, prop: any, newData: any) {
|
|
986
|
+
// Make sure newData is unproxied
|
|
987
|
+
if (typeof newData === 'object' && newData) newData = (newData as any)[TARGET_SYMBOL] || newData;
|
|
988
|
+
const oldData = target[prop];
|
|
989
|
+
if (newData !== oldData) {
|
|
990
|
+
let oldLength = target.length;
|
|
991
|
+
|
|
992
|
+
if (prop === 'length') {
|
|
993
|
+
target.length = newData;
|
|
937
994
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
currentScope._cleaners.push(this)
|
|
995
|
+
// We only need to emit for shrinking, as growing just adds undefineds
|
|
996
|
+
for(let i=newData; i<oldLength; i++) {
|
|
997
|
+
emit(target, i, undefined, target[i]);
|
|
942
998
|
}
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
result[k] = (v instanceof ObsCollection) ? (depth ? v._getRecursive(depth-1) : new Store(this,k)) : v
|
|
947
|
-
})
|
|
948
|
-
return result
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
_merge(newValue: any, deleteMissing: boolean): boolean {
|
|
952
|
-
if (!newValue || newValue.constructor !== Object) {
|
|
953
|
-
return false
|
|
954
|
-
}
|
|
999
|
+
} else {
|
|
1000
|
+
const intProp = parseInt(prop)
|
|
1001
|
+
if (intProp.toString() === prop) prop = intProp;
|
|
955
1002
|
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
this._setIndex(k, newValue[k], deleteMissing)
|
|
1003
|
+
target[prop] = newData;
|
|
1004
|
+
emit(target, prop, newData, oldData);
|
|
959
1005
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
this.data.forEach((v: DatumType, k: any) => {
|
|
963
|
-
if (!newValue.hasOwnProperty(k)) this._setIndex(k, undefined, false)
|
|
964
|
-
})
|
|
1006
|
+
if (target.length !== oldLength) {
|
|
1007
|
+
emit(target, 'length', target.length, oldLength);
|
|
965
1008
|
}
|
|
966
|
-
|
|
967
|
-
return true
|
|
1009
|
+
runImmediateQueue();
|
|
968
1010
|
}
|
|
969
|
-
|
|
970
|
-
_normalizeIndex(index: any): any {
|
|
971
|
-
let type = typeof index
|
|
972
|
-
if (type==='string') return index
|
|
973
|
-
if (type==='number') return ''+index
|
|
974
|
-
throw new Error(`Invalid object index ${JSON.stringify(index)}`)
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
_getCount() {
|
|
978
|
-
let cnt = 0
|
|
979
|
-
for(let key of this.data) cnt++
|
|
980
|
-
return cnt
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
const DETACHED_KEY: any = {}
|
|
987
|
-
|
|
988
|
-
/*
|
|
989
|
-
* A data store that automatically subscribes the current Scope to updates
|
|
990
|
-
* whenever data is read from it.
|
|
991
|
-
*
|
|
992
|
-
* Supported data types are: `string`, `number`, `boolean`, `undefined`, `null`,
|
|
993
|
-
* `Array`, `object` and `Map`. The latter three will always have `Store` objects as
|
|
994
|
-
* values, creating a tree of `Store`-objects.
|
|
995
|
-
*/
|
|
996
|
-
|
|
997
|
-
export interface Store {
|
|
998
|
-
/**
|
|
999
|
-
* Return a `Store` deeper within the tree by resolving the given `path`,
|
|
1000
|
-
* subscribing to every level.
|
|
1001
|
-
* In case `undefined` is encountered while resolving the path, a newly
|
|
1002
|
-
* created `Store` containing `undefined` is returned. In that case, the
|
|
1003
|
-
* `Store`'s [[`isDetached`]] method will return `true`.
|
|
1004
|
-
* In case something other than a collection is encountered, an error is thrown.
|
|
1005
|
-
*/
|
|
1006
|
-
(...path: any[]): Store
|
|
1011
|
+
return true;
|
|
1007
1012
|
}
|
|
1008
1013
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
private _virtual: string[] | undefined
|
|
1017
|
-
|
|
1018
|
-
/**
|
|
1019
|
-
* Create a new `Store` with `undefined` as its initial value.
|
|
1020
|
-
*/
|
|
1021
|
-
constructor()
|
|
1022
|
-
/**
|
|
1023
|
-
* Create a new `Store`.
|
|
1024
|
-
* @param value The initial value. Plain objects, arrays and `Map`s, are converted
|
|
1025
|
-
* into a tree of nested `Store`s. When another `Store` is included somewhere in that
|
|
1026
|
-
* input tree, a reference is made.
|
|
1027
|
-
*/
|
|
1028
|
-
constructor(value: any)
|
|
1029
|
-
|
|
1030
|
-
/** @internal */
|
|
1031
|
-
constructor(collection: ObsCollection, index: any)
|
|
1032
|
-
|
|
1033
|
-
/** @internal */
|
|
1034
|
-
constructor(value: any = undefined, index: any = undefined) {
|
|
1035
|
-
/**
|
|
1036
|
-
* Create and return a new `Store` that represents the subtree at `path` of
|
|
1037
|
-
* the current `Store`.
|
|
1038
|
-
*
|
|
1039
|
-
* The `path` is only actually resolved when this new `Store` is first used,
|
|
1040
|
-
* and how this is done depends on whether a read or a write operation is
|
|
1041
|
-
* performed. Read operations will just use an `undefined` value when a
|
|
1042
|
-
* subtree that we're diving into does not exist. Also, they'll subscribe
|
|
1043
|
-
* to changes at each level of the tree indexed by `path`.
|
|
1044
|
-
*
|
|
1045
|
-
* Write operations will create any missing subtrees as objects. They don't
|
|
1046
|
-
* subscribe to changes (as they are the ones causing the changes).
|
|
1047
|
-
*
|
|
1048
|
-
* Both read and write operations will throw an error if, while resolving
|
|
1049
|
-
* `path`, they encounters a non-collection data type (such as a number)
|
|
1050
|
-
*/
|
|
1051
|
-
const ref: Store = function(...path: any): Store {
|
|
1052
|
-
const result = new Store(ref._collection, ref._idx)
|
|
1053
|
-
if (path.length || ref._virtual) {
|
|
1054
|
-
result._virtual = ref._virtual ? ref._virtual.concat(path) : path
|
|
1055
|
-
}
|
|
1056
|
-
return result
|
|
1057
|
-
} as Store
|
|
1058
|
-
|
|
1059
|
-
Object.setPrototypeOf(ref, Store.prototype)
|
|
1060
|
-
if (index===undefined) {
|
|
1061
|
-
ref._collection = new ObsArray()
|
|
1062
|
-
ref._idx = 0
|
|
1063
|
-
if (value!==undefined) {
|
|
1064
|
-
ref._collection.rawSet(0, valueToData(value))
|
|
1065
|
-
}
|
|
1066
|
-
} else {
|
|
1067
|
-
if (!(value instanceof ObsCollection)) {
|
|
1068
|
-
throw new Error("1st parameter should be an ObsCollection if the 2nd is also given")
|
|
1069
|
-
}
|
|
1070
|
-
ref._collection = value
|
|
1071
|
-
ref._idx = index
|
|
1014
|
+
const arrayHandler: ProxyHandler<any[]> = {
|
|
1015
|
+
get(target: any, prop: any) {
|
|
1016
|
+
if (prop===TARGET_SYMBOL) return target;
|
|
1017
|
+
let subProp = prop;
|
|
1018
|
+
if (typeof prop !== 'symbol') {
|
|
1019
|
+
const intProp = parseInt(prop);
|
|
1020
|
+
if (intProp.toString() === prop) subProp = intProp;
|
|
1072
1021
|
}
|
|
1073
|
-
|
|
1074
|
-
return
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
*
|
|
1082
|
-
* @example
|
|
1083
|
-
* ```
|
|
1084
|
-
* let store = new Store({x: 123})
|
|
1085
|
-
* let subStore = store.ref('x')
|
|
1086
|
-
* subStore.get() // 123
|
|
1087
|
-
* subStore.index() // 'x'
|
|
1088
|
-
* ```
|
|
1089
|
-
*/
|
|
1090
|
-
index() {
|
|
1091
|
-
return this._idx
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
/** @internal */
|
|
1095
|
-
_clean(scope: Scope) {
|
|
1096
|
-
this._collection._removeObserver(this._idx, scope)
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
/**
|
|
1100
|
-
* Retrieve the value for store, subscribing the observe scope to changes.
|
|
1101
|
-
*
|
|
1102
|
-
* @param depth Limit the depth of the retrieved data structure to this positive integer.
|
|
1103
|
-
* When `depth` is `1`, only a single level of the value at `path` is unpacked. This
|
|
1104
|
-
* makes no difference for primitive values (like strings), but for objects, maps and
|
|
1105
|
-
* arrays, it means that each *value* in the resulting data structure will be a
|
|
1106
|
-
* reference to the `Store` for that value.
|
|
1107
|
-
*
|
|
1108
|
-
* @returns The resulting value (or `undefined` if the `Store` does not exist).
|
|
1109
|
-
*/
|
|
1110
|
-
get(depth: number = 0): any {
|
|
1111
|
-
let value = this._observe()
|
|
1112
|
-
return value instanceof ObsCollection ? value._getRecursive(depth-1) : value
|
|
1113
|
-
}
|
|
1022
|
+
subscribe(target, subProp);
|
|
1023
|
+
return optProxy(target[prop]);
|
|
1024
|
+
},
|
|
1025
|
+
set: arraySet,
|
|
1026
|
+
deleteProperty(target: any, prop: string|symbol) {
|
|
1027
|
+
return arraySet(target, prop, undefined);
|
|
1028
|
+
},
|
|
1029
|
+
};
|
|
1114
1030
|
|
|
1115
|
-
|
|
1116
|
-
* Exactly like {@link Store.get}, except that when executed from an observe scope,
|
|
1117
|
-
* we will not subscribe to changes in the data retrieved data.
|
|
1118
|
-
*/
|
|
1119
|
-
peek(depth: number = 0): any {
|
|
1031
|
+
const proxyMap = new WeakMap<TargetType, /*Proxy*/TargetType>();
|
|
1120
1032
|
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
return result
|
|
1033
|
+
function optProxy(value: any): any {
|
|
1034
|
+
// If value is a primitive type or already proxied, just return it
|
|
1035
|
+
if (typeof value !== 'object' || !value || value[TARGET_SYMBOL] !== undefined) {
|
|
1036
|
+
return value;
|
|
1126
1037
|
}
|
|
1038
|
+
let proxied = proxyMap.get(value);
|
|
1039
|
+
if (proxied) return proxied // Only one proxy per target!
|
|
1127
1040
|
|
|
1041
|
+
proxied = new Proxy(value, value instanceof Array ? arrayHandler : objectHandler);
|
|
1042
|
+
proxyMap.set(value, proxied as TargetType);
|
|
1043
|
+
return proxied;
|
|
1044
|
+
}
|
|
1128
1045
|
|
|
1129
|
-
/**
|
|
1130
|
-
* Like {@link Store.get}, but with return type checking.
|
|
1131
|
-
*
|
|
1132
|
-
* @param expectType A string specifying what type the.get is expected to return. Options are:
|
|
1133
|
-
* "undefined", "null", "boolean", "number", "string", "function", "array", "map"
|
|
1134
|
-
* and "object". If the store holds a different type of value, a `TypeError`
|
|
1135
|
-
* exception is thrown.
|
|
1136
|
-
* @returns
|
|
1137
|
-
*/
|
|
1138
|
-
getTyped(expectType: String, depth: number = 0): any {
|
|
1139
|
-
let value = this._observe()
|
|
1140
|
-
let type = (value instanceof ObsCollection) ? value._getType() : (value===null ? "null" : typeof value)
|
|
1141
|
-
if (type !== expectType) throw new TypeError(`Expecting ${expectType} but got ${type}`)
|
|
1142
|
-
return value instanceof ObsCollection ? value._getRecursive(depth-1) : value
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
/**
|
|
1146
|
-
* @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `number`.
|
|
1147
|
-
* Using this instead of just {@link Store.get} is especially useful from within TypeScript.
|
|
1148
|
-
*/
|
|
1149
|
-
getNumber(): number { return <number>this.getTyped('number') }
|
|
1150
|
-
/**
|
|
1151
|
-
* @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `string`.
|
|
1152
|
-
* Using this instead of just {@link Store.get} is especially useful from within TypeScript.
|
|
1153
|
-
*/
|
|
1154
|
-
getString(): string { return <string>this.getTyped('string') }
|
|
1155
|
-
/**
|
|
1156
|
-
* @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `boolean`.
|
|
1157
|
-
* Using this instead of just {@link Store.get} is especially useful from within TypeScript.
|
|
1158
|
-
*/
|
|
1159
|
-
getBoolean(): boolean { return <boolean>this.getTyped('boolean') }
|
|
1160
|
-
/**
|
|
1161
|
-
* @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `function`.
|
|
1162
|
-
* Using this instead of just {@link Store.get} is especially useful from within TypeScript.
|
|
1163
|
-
*/
|
|
1164
|
-
getFunction(): (Function) { return <Function>this.getTyped('function') }
|
|
1165
|
-
/**
|
|
1166
|
-
* @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `array`.
|
|
1167
|
-
* Using this instead of just {@link Store.get} is especially useful from within TypeScript.
|
|
1168
|
-
*/
|
|
1169
|
-
getArray(depth: number = 0): any[] { return <any[]>this.getTyped('array', depth) }
|
|
1170
|
-
/**
|
|
1171
|
-
* @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `object`.
|
|
1172
|
-
* Using this instead of just {@link Store.get} is especially useful from within TypeScript.
|
|
1173
|
-
*/
|
|
1174
|
-
getObject(depth: number = 0): object { return <object>this.getTyped('object', depth) }
|
|
1175
|
-
/**
|
|
1176
|
-
* @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `map`.
|
|
1177
|
-
* Using this instead of just {@link Store.get} is especially useful from within TypeScript.
|
|
1178
|
-
*/
|
|
1179
|
-
getMap(depth: number = 0): Map<any,any> { return <Map<any,any>>this.getTyped('map', depth) }
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
/**
|
|
1183
|
-
* Like {@link Store.get}, but with a default value (returned when the Store
|
|
1184
|
-
* contains `undefined`). This default value is also used to determine the expected type,
|
|
1185
|
-
* and to throw otherwise.
|
|
1186
|
-
*
|
|
1187
|
-
* @example
|
|
1188
|
-
* ```
|
|
1189
|
-
* let store = new Store({x: 42})
|
|
1190
|
-
* store('x').getOr(99) // 42
|
|
1191
|
-
* store('y').getOr(99) // 99
|
|
1192
|
-
* store('x').getOr('hello') // throws TypeError (because 42 is not a string)
|
|
1193
|
-
* ```
|
|
1194
|
-
*/
|
|
1195
|
-
getOr<T>(defaultValue: T): T {
|
|
1196
|
-
let value = this._observe()
|
|
1197
|
-
if (value===undefined) return defaultValue
|
|
1198
|
-
|
|
1199
|
-
let expectType: string = typeof defaultValue
|
|
1200
|
-
if (expectType==='object') {
|
|
1201
|
-
if (defaultValue instanceof Map) expectType = 'map'
|
|
1202
|
-
else if (defaultValue instanceof Array) expectType = 'array'
|
|
1203
|
-
else if (defaultValue === null) expectType = 'null'
|
|
1204
|
-
}
|
|
1205
|
-
let type = (value instanceof ObsCollection) ? value._getType() : (value===null ? "null" : typeof value)
|
|
1206
|
-
if (type !== expectType) throw new TypeError(`Expecting ${expectType} but got ${type}`)
|
|
1207
|
-
return (value instanceof ObsCollection ? value._getRecursive(-1) : value) as T
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
/**
|
|
1211
|
-
* Checks if the collection held in `Store` is empty, and subscribes the current scope to changes of the emptiness of this collection.
|
|
1212
|
-
*
|
|
1213
|
-
* @returns When the collection is not empty `true` is returned. If it is empty or if the value is undefined, `false` is returned.
|
|
1214
|
-
* @throws When the value is not a collection and not undefined, an Error will be thrown.
|
|
1215
|
-
*/
|
|
1216
|
-
isEmpty(): boolean {
|
|
1217
|
-
let value = this._observe()
|
|
1218
|
-
if (value instanceof ObsCollection) {
|
|
1219
|
-
if (currentScope) {
|
|
1220
|
-
let observer = new IsEmptyObserver(currentScope, value, false)
|
|
1221
|
-
return !observer.count
|
|
1222
|
-
} else {
|
|
1223
|
-
return !value._getCount()
|
|
1224
|
-
}
|
|
1225
|
-
} else if (value===undefined) {
|
|
1226
|
-
return true
|
|
1227
|
-
} else {
|
|
1228
|
-
throw new Error(`isEmpty() expects a collection or undefined, but got ${JSON.stringify(value)}`)
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
/**
|
|
1233
|
-
* Returns the number of items in the collection held in Store, and subscribes the current scope to changes in this count.
|
|
1234
|
-
*
|
|
1235
|
-
* @returns The number of items contained in the collection, or 0 if the value is undefined.
|
|
1236
|
-
* @throws When the value is not a collection and not undefined, an Error will be thrown.
|
|
1237
|
-
*/
|
|
1238
|
-
count(): number {
|
|
1239
|
-
let value = this._observe()
|
|
1240
|
-
if (value instanceof ObsCollection) {
|
|
1241
|
-
if (currentScope) {
|
|
1242
|
-
let observer = new IsEmptyObserver(currentScope, value, true)
|
|
1243
|
-
return observer.count
|
|
1244
|
-
} else {
|
|
1245
|
-
return value._getCount()
|
|
1246
|
-
}
|
|
1247
|
-
} else if (value===undefined) {
|
|
1248
|
-
return 0
|
|
1249
|
-
} else {
|
|
1250
|
-
throw new Error(`count() expects a collection or undefined, but got ${JSON.stringify(value)}`)
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
/**
|
|
1255
|
-
* Returns a strings describing the type of the `Store` value, subscribing to changes of this type.
|
|
1256
|
-
* Note: this currently also subscribes to changes of primitive values, so changing a value from 3 to 4
|
|
1257
|
-
* would cause the scope to be rerun. This is not great, and may change in the future. This caveat does
|
|
1258
|
-
* not apply to changes made *inside* an object, `Array` or `Map`.
|
|
1259
|
-
*
|
|
1260
|
-
* @returns Possible options: "undefined", "null", "boolean", "number", "string", "function", "array", "map" or "object".
|
|
1261
|
-
*/
|
|
1262
|
-
getType(): string {
|
|
1263
|
-
let value = this._observe()
|
|
1264
|
-
return (value instanceof ObsCollection) ? value._getType() : (value===null ? "null" : typeof value)
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
/**
|
|
1268
|
-
* Returns a new `Store` that will always hold either the value of `whenTrue` or the value
|
|
1269
|
-
* of `whenFalse` depending on whether the original `Store` is truthy or not.
|
|
1270
|
-
*
|
|
1271
|
-
* @param whenTrue The value set to the return-`Store` while `this` is truthy. This can be
|
|
1272
|
-
* any type of value. If it's a `Store`, the return-`Store` will reference the same
|
|
1273
|
-
* data (so *no* deep copy will be made).
|
|
1274
|
-
* @param whenFalse Like `whenTrue`, but for falsy values (false, undefined, null, 0, "").
|
|
1275
|
-
* @returns A store holding the result value. The value will keep getting updated while
|
|
1276
|
-
* the observe context from which `if()` was called remains active.
|
|
1277
|
-
*/
|
|
1278
|
-
if(whenTrue: any[], whenFalse?: any[]): Store {
|
|
1279
|
-
const result = new Store()
|
|
1280
|
-
observe(() => {
|
|
1281
|
-
const value = this.get() ? whenTrue : whenFalse
|
|
1282
|
-
result.set(value)
|
|
1283
|
-
})
|
|
1284
|
-
return result
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
/**
|
|
1288
|
-
* Sets the `Store` value to the given argument.
|
|
1289
|
-
*
|
|
1290
|
-
* When a `Store` is passed in as the value, its value will be copied (subscribing to changes). In
|
|
1291
|
-
* case the value is an object, an `Array` or a `Map`, a *reference* to that data structure will
|
|
1292
|
-
* be created, so that changes made through one `Store` will be reflected through the other. Be
|
|
1293
|
-
* carefull not to create loops in your `Store` tree that way, as that would cause any future
|
|
1294
|
-
* call to {@link Store.get} to throw a `RangeError` (Maximum call stack size exceeded.)
|
|
1295
|
-
*
|
|
1296
|
-
* If you intent to make a copy instead of a reference, call {@link Store.get} on the origin `Store`.
|
|
1297
|
-
*
|
|
1298
|
-
* @returns The `Store` itself, for chaining other methods.
|
|
1299
|
-
*
|
|
1300
|
-
* @example
|
|
1301
|
-
* ```
|
|
1302
|
-
* let store = new Store() // Value is `undefined`
|
|
1303
|
-
*
|
|
1304
|
-
* store.set(6)
|
|
1305
|
-
* store.get() // 6
|
|
1306
|
-
*
|
|
1307
|
-
* store.set({}) // Change value to an empty object
|
|
1308
|
-
* store('a', 'b', 'c').set('d') // Create parent path as objects
|
|
1309
|
-
* store.get() // {x: 6, a: {b: {c: 'd'}}}
|
|
1310
|
-
*
|
|
1311
|
-
* store.set(42) // Overwrites all of the above
|
|
1312
|
-
* store.get() // 42
|
|
1313
|
-
*
|
|
1314
|
-
* store('x').set(6) // Throw Error (42 is not a collection)
|
|
1315
|
-
* ```
|
|
1316
|
-
*/
|
|
1317
|
-
set(newValue: any): Store {
|
|
1318
|
-
this._materialize(true)
|
|
1319
|
-
this._collection._setIndex(this._idx, newValue, true)
|
|
1320
|
-
runImmediateQueue()
|
|
1321
|
-
return this
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
/** @internal */
|
|
1325
|
-
_materialize(forWriting: boolean): boolean {
|
|
1326
|
-
if (!this._virtual) return true
|
|
1327
|
-
let collection = this._collection
|
|
1328
|
-
let idx = this._idx
|
|
1329
|
-
for(let i=0; i<this._virtual.length; i++) {
|
|
1330
|
-
if (!forWriting && currentScope) {
|
|
1331
|
-
if (collection._addObserver(idx, currentScope)) {
|
|
1332
|
-
currentScope._cleaners.push(this)
|
|
1333
|
-
}
|
|
1334
|
-
}
|
|
1335
|
-
let value = collection.rawGet(idx)
|
|
1336
|
-
if (!(value instanceof ObsCollection)) {
|
|
1337
|
-
// Throw an error if trying to index a primitive type
|
|
1338
|
-
if (value!==undefined) throw new Error(`While resolving ${JSON.stringify(this._virtual)}, found ${JSON.stringify(value)} at index ${i} instead of a collection.`)
|
|
1339
|
-
// For reads, we'll just give up. We might reactively get another shot at this.
|
|
1340
|
-
if (!forWriting) return false
|
|
1341
|
-
// For writes, create a new collection.
|
|
1342
|
-
value = new ObsObject()
|
|
1343
|
-
collection.rawSet(idx, value)
|
|
1344
|
-
collection.emitChange(idx, value, undefined)
|
|
1345
|
-
}
|
|
1346
|
-
collection = value
|
|
1347
|
-
const prop = this._virtual[i]
|
|
1348
|
-
idx = collection._normalizeIndex(prop)
|
|
1349
|
-
}
|
|
1350
|
-
this._collection = collection
|
|
1351
|
-
this._idx = idx
|
|
1352
|
-
delete this._virtual
|
|
1353
|
-
return true
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
/**
|
|
1357
|
-
* Sets the `Store` to the given `mergeValue`, but without deleting any pre-existing
|
|
1358
|
-
* items when a collection overwrites a similarly typed collection. This results in
|
|
1359
|
-
* a deep merge.
|
|
1360
|
-
*
|
|
1361
|
-
* @returns The `Store` itself, for chaining other methods.
|
|
1362
|
-
*
|
|
1363
|
-
* @example
|
|
1364
|
-
* ```
|
|
1365
|
-
* let store = new Store({a: {x: 1}})
|
|
1366
|
-
* store.merge({a: {y: 2}, b: 3})
|
|
1367
|
-
* store.get() // {a: {x: 1, y: 2}, b: 3}
|
|
1368
|
-
* ```
|
|
1369
|
-
*/
|
|
1370
|
-
merge(mergeValue: any): Store {
|
|
1371
|
-
this._materialize(true)
|
|
1372
|
-
this._collection._setIndex(this._idx, mergeValue, false)
|
|
1373
|
-
runImmediateQueue()
|
|
1374
|
-
return this
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
/**
|
|
1378
|
-
* Sets the value for the store to `undefined`, which causes it to be omitted from the map (or array, if it's at the end)
|
|
1379
|
-
*
|
|
1380
|
-
* @returns The `Store` itself, for chaining other methods.
|
|
1381
|
-
*
|
|
1382
|
-
* @example
|
|
1383
|
-
* ```
|
|
1384
|
-
* let store = new Store({a: 1, b: 2})
|
|
1385
|
-
* store('a').delete()
|
|
1386
|
-
* store.get() // {b: 2}
|
|
1387
|
-
*
|
|
1388
|
-
* store = new Store(['a','b','c'])
|
|
1389
|
-
* store(1).delete()
|
|
1390
|
-
* store.get() // ['a', undefined, 'c']
|
|
1391
|
-
* store(2).delete()
|
|
1392
|
-
* store.get() // ['a']
|
|
1393
|
-
* store.delete()
|
|
1394
|
-
* store.get() // undefined
|
|
1395
|
-
* ```
|
|
1396
|
-
*/
|
|
1397
|
-
delete(): Store {
|
|
1398
|
-
this._materialize(true)
|
|
1399
|
-
this._collection._setIndex(this._idx, undefined, true)
|
|
1400
|
-
runImmediateQueue()
|
|
1401
|
-
return this
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
/**
|
|
1405
|
-
* Pushes a value to the end of the Array that is at the specified path in the store.
|
|
1406
|
-
* If that store path is `undefined`, an Array is created first.
|
|
1407
|
-
* The last argument is the value to be added, any earlier arguments indicate the path.
|
|
1408
|
-
*
|
|
1409
|
-
* @returns The index at which the item was appended.
|
|
1410
|
-
* @throws TypeError when the store contains a primitive data type.
|
|
1411
|
-
*
|
|
1412
|
-
* @example
|
|
1413
|
-
* ```
|
|
1414
|
-
* let store = new Store()
|
|
1415
|
-
* store.push(3) // Creates the array
|
|
1416
|
-
* store.push(6)
|
|
1417
|
-
* store.get() // [3,6]
|
|
1418
|
-
*
|
|
1419
|
-
* store = new Store({myArray: [1,2]})
|
|
1420
|
-
* store('myArray').push(3)
|
|
1421
|
-
* store.get() // {myArray: [1,2,3]}
|
|
1422
|
-
* ```
|
|
1423
|
-
*/
|
|
1424
|
-
push(newValue: any): number {
|
|
1425
|
-
this._materialize(true)
|
|
1426
|
-
|
|
1427
|
-
let obsArray = this._collection.rawGet(this._idx)
|
|
1428
|
-
if (obsArray===undefined) {
|
|
1429
|
-
obsArray = new ObsArray()
|
|
1430
|
-
this._collection._setIndex(this._idx, obsArray, true)
|
|
1431
|
-
} else if (!(obsArray instanceof ObsArray)) {
|
|
1432
|
-
throw new TypeError(`push() is only allowed for an array or undefined (which would become an array)`)
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
let newData = valueToData(newValue)
|
|
1436
|
-
let pos = obsArray._data.length
|
|
1437
|
-
obsArray._data.push(newData)
|
|
1438
|
-
obsArray.emitChange(pos, newData, undefined)
|
|
1439
|
-
runImmediateQueue()
|
|
1440
|
-
return pos
|
|
1441
|
-
}
|
|
1442
1046
|
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
* @param func The function transforming the value.
|
|
1447
|
-
* @returns The `Store` itself, for chaining other methods.
|
|
1448
|
-
*/
|
|
1449
|
-
modify(func: (value: any) => any): Store {
|
|
1450
|
-
this.set(func(this.peek()))
|
|
1451
|
-
return this
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
/** @internal */
|
|
1455
|
-
_observe() {
|
|
1456
|
-
if (!this._materialize(false)) return undefined
|
|
1457
|
-
if (currentScope) {
|
|
1458
|
-
if (this._collection._addObserver(this._idx, currentScope)) {
|
|
1459
|
-
currentScope._cleaners.push(this)
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
return this._collection.rawGet(this._idx)
|
|
1463
|
-
}
|
|
1047
|
+
export function proxy<T extends DatumType>(target: Array<T>): Array<T extends number ? number : T extends string ? string : T extends boolean ? boolean : T>;
|
|
1048
|
+
export function proxy<T extends object>(target: T): T;
|
|
1049
|
+
export function proxy<T extends DatumType>(target: T): ValueRef<T extends number ? number : T extends string ? string : T extends boolean ? boolean : T>;
|
|
1464
1050
|
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1051
|
+
/**
|
|
1052
|
+
* Creates a reactive proxy around the given data.
|
|
1053
|
+
*
|
|
1054
|
+
* Reading properties from the returned proxy within a reactive scope (like one created by
|
|
1055
|
+
* {@link $} or {@link observe}) establishes a subscription. Modifying properties *through*
|
|
1056
|
+
* the proxy will notify subscribed scopes, causing them to re-execute.
|
|
1057
|
+
*
|
|
1058
|
+
* - Plain objects and arrays are wrapped in a standard JavaScript `Proxy` that intercepts
|
|
1059
|
+
* property access and mutations, but otherwise works like the underlying data.
|
|
1060
|
+
* - Primitives (string, number, boolean, null, undefined) are wrapped in an object
|
|
1061
|
+
* `{ value: T }` which is then proxied. Access the primitive via the `.value` property.
|
|
1062
|
+
*
|
|
1063
|
+
* Use {@link unproxy} to get the original underlying data back.
|
|
1064
|
+
*
|
|
1065
|
+
* @param target - The object, array, or primitive value to make reactive.
|
|
1066
|
+
* @returns A reactive proxy wrapping the target data.
|
|
1067
|
+
* @template T - The type of the data being proxied.
|
|
1068
|
+
*
|
|
1069
|
+
* @example Object
|
|
1070
|
+
* ```javascript
|
|
1071
|
+
* const state = proxy({ count: 0, message: 'Hello' });
|
|
1072
|
+
* observe(() => console.log(state.message)); // Subscribes to message
|
|
1073
|
+
* setTimeout(() => state.message = 'World', 1000); // Triggers the observe function
|
|
1074
|
+
* setTimeout(() => state.count++, 2000); // Triggers nothing
|
|
1075
|
+
* ```
|
|
1076
|
+
*
|
|
1077
|
+
* @example Array
|
|
1078
|
+
* ```javascript
|
|
1079
|
+
* const items = proxy(['a', 'b']);
|
|
1080
|
+
* observe(() => console.log(items.length)); // Subscribes to length
|
|
1081
|
+
* setTimeout(() => items.push('c'), 2000); // Triggers the observe function
|
|
1082
|
+
* ```
|
|
1083
|
+
*
|
|
1084
|
+
* @example Primitive
|
|
1085
|
+
* ```javascript
|
|
1086
|
+
* const name = proxy('Aberdeen');
|
|
1087
|
+
* observe(() => console.log(name.value)); // Subscribes to value
|
|
1088
|
+
* setTimeout(() => name.value = 'UI', 2000); // Triggers the observe function
|
|
1089
|
+
* ```
|
|
1090
|
+
*
|
|
1091
|
+
* @example Class instance
|
|
1092
|
+
* ```typescript
|
|
1093
|
+
* class Widget {
|
|
1094
|
+
* constructor(public name: string, public width: number, public height: number) {}
|
|
1095
|
+
* grow() { this.width *= 2; }
|
|
1096
|
+
* toString() { return `${this.name}Widget (${this.width}x${this.height})`; }
|
|
1097
|
+
* }
|
|
1098
|
+
* let graph: Widget = proxy(new Widget('Graph', 200, 100));
|
|
1099
|
+
* observe(() => console.log(''+graph));
|
|
1100
|
+
* setTimeout(() => graph.grow(), 2000);
|
|
1101
|
+
* setTimeout(() => graph.grow(), 4000);
|
|
1102
|
+
* ```
|
|
1103
|
+
*/
|
|
1104
|
+
export function proxy(target: TargetType): TargetType {
|
|
1105
|
+
return optProxy(typeof target === 'object' && target !== null ? target : {value: target});
|
|
1106
|
+
}
|
|
1480
1107
|
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1108
|
+
/**
|
|
1109
|
+
* Returns the original, underlying data target from a reactive proxy created by {@link proxy}.
|
|
1110
|
+
* If the input `target` is not a proxy, it is returned directly.
|
|
1111
|
+
*
|
|
1112
|
+
* This is useful when you want to avoid triggering subscriptions during read operations or
|
|
1113
|
+
* re-executes during write operations. Using {@link peek} is an alternative way to achieve this.
|
|
1114
|
+
*
|
|
1115
|
+
* @param target - A proxied object, array, or any other value.
|
|
1116
|
+
* @returns The underlying (unproxied) data, or the input value if it wasn't a proxy.
|
|
1117
|
+
* @template T - The type of the target.
|
|
1118
|
+
*
|
|
1119
|
+
* @example
|
|
1120
|
+
* ```typescript
|
|
1121
|
+
* const userProxy = proxy({ name: 'Frank' });
|
|
1122
|
+
* const rawUser = unproxy(userProxy);
|
|
1123
|
+
*
|
|
1124
|
+
* // Log reactively
|
|
1125
|
+
* $(() => console.log('proxied', userProxy.name));
|
|
1126
|
+
* // The following will only ever log once, as we're not subscribing to any observable
|
|
1127
|
+
* $(() => console.log('unproxied', rawUser.name));
|
|
1128
|
+
*
|
|
1129
|
+
* // This cause the first log to run again:
|
|
1130
|
+
* setTimeout(() => userProxy.name += '!', 1000);
|
|
1131
|
+
*
|
|
1132
|
+
* // This doesn't cause any new logs:
|
|
1133
|
+
* setTimeout(() => rawUser.name += '?', 2000);
|
|
1134
|
+
*
|
|
1135
|
+
* // Both userProxy and rawUser end up as `{name: 'Frank!?'}`
|
|
1136
|
+
* setTimeout(() => console.log('final values', userProxy, rawUser), 3000);
|
|
1137
|
+
* ```
|
|
1138
|
+
*/
|
|
1139
|
+
export function unproxy<T>(target: T): T {
|
|
1140
|
+
return target ? (target as any)[TARGET_SYMBOL] || target : target;
|
|
1141
|
+
}
|
|
1486
1142
|
|
|
1487
|
-
|
|
1488
|
-
currentScope._lastChild = onEachScope
|
|
1143
|
+
let onDestroyMap: WeakMap<Node, string | Function | true> = new WeakMap();
|
|
1489
1144
|
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1145
|
+
function destroyWithClass(element: Element, cls: string) {
|
|
1146
|
+
const classes = cls.split('.').filter(c=>c);
|
|
1147
|
+
element.classList.add(...classes);
|
|
1148
|
+
setTimeout(() => element.remove(), 2000);
|
|
1149
|
+
}
|
|
1495
1150
|
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1151
|
+
/**
|
|
1152
|
+
* Recursively copies properties or array items from `src` to `dst`.
|
|
1153
|
+
* It's designed to work efficiently with reactive proxies created by {@link proxy}.
|
|
1154
|
+
*
|
|
1155
|
+
* - **Minimizes Updates:** When copying between objects/arrays (proxied or not), if a nested object
|
|
1156
|
+
* exists in `dst` with the same constructor as the corresponding object in `src`, `copy`
|
|
1157
|
+
* will recursively copy properties into the existing `dst` object instead of replacing it.
|
|
1158
|
+
* This minimizes change notifications for reactive updates.
|
|
1159
|
+
* - **Handles Proxies:** Can accept proxied or unproxied objects/arrays for both `dst` and `src`.
|
|
1160
|
+
*
|
|
1161
|
+
* @param dst - The destination object/array (proxied or unproxied).
|
|
1162
|
+
* @param src - The source object/array (proxied or unproxied). It won't be modified.
|
|
1163
|
+
* @param flags - Bitmask controlling copy behavior:
|
|
1164
|
+
* - {@link MERGE}: Performs a partial update. Properties in `dst` not present in `src` are kept.
|
|
1165
|
+
* `null`/`undefined` in `src` delete properties in `dst`. Handles partial array updates via object keys.
|
|
1166
|
+
* - {@link SHALLOW}: Performs a shallow copy; when an array/object of the right type doesn't exist in `dst` yet, a reference to the array/object in `src` will be made, instead of creating a copy. If the array/object already exists, it won't be replaced (by a reference), but all items will be individually checked and copied like normal, keeping changes (and therefore UI updates) to a minimum.
|
|
1167
|
+
* @template T - The type of the objects being copied.
|
|
1168
|
+
* @throws Error if attempting to copy an array into a non-array or vice versa (unless {@link MERGE} is set, allowing for sparse array updates).
|
|
1169
|
+
*
|
|
1170
|
+
* @example Basic Copy
|
|
1171
|
+
* ```typescript
|
|
1172
|
+
* const source = proxy({ a: 1, b: { c: 2 } });
|
|
1173
|
+
* const dest = proxy({ b: { d: 3 } });
|
|
1174
|
+
* copy(dest, source);
|
|
1175
|
+
* console.log(dest); // proxy({ a: 1, b: { c: 2 } })
|
|
1176
|
+
* ```
|
|
1177
|
+
*
|
|
1178
|
+
* @example MERGE
|
|
1179
|
+
* ```typescript
|
|
1180
|
+
* const source = { b: { c: 99 }, d: undefined }; // d: undefined will delete
|
|
1181
|
+
* const dest = proxy({ a: 1, b: { x: 5 }, d: 4 });
|
|
1182
|
+
* copy(dest, source, MERGE);
|
|
1183
|
+
* console.log(dest); // proxy({ a: 1, b: { c: 99, x: 5 } })
|
|
1184
|
+
* ```
|
|
1185
|
+
*
|
|
1186
|
+
* @example Partial Array Update with MERGE
|
|
1187
|
+
* ```typescript
|
|
1188
|
+
* const messages = proxy(['msg1', 'msg2', 'msg3']);
|
|
1189
|
+
* const update = { 1: 'updated msg2' }; // Update using object key as index
|
|
1190
|
+
* copy(messages, update, MERGE);
|
|
1191
|
+
* console.log(messages); // proxy(['msg1', 'updated msg2', 'msg3'])
|
|
1192
|
+
* ```
|
|
1193
|
+
*
|
|
1194
|
+
* @example SHALLOW
|
|
1195
|
+
* ```typescript
|
|
1196
|
+
* const source = { nested: [1, 2] };
|
|
1197
|
+
* const dest = {};
|
|
1198
|
+
* copy(dest, source, SHALLOW);
|
|
1199
|
+
* dest.nested.push(3);
|
|
1200
|
+
* console.log(source.nested); // [1, 2, 3] (source was modified)
|
|
1201
|
+
* ```
|
|
1202
|
+
*/
|
|
1203
|
+
export function copy<T extends object>(dst: T, src: T, flags: number = 0) {
|
|
1204
|
+
copyRecurse(dst, src, flags);
|
|
1205
|
+
runImmediateQueue();
|
|
1206
|
+
}
|
|
1207
|
+
/** Flag to {@link copy} causing it to use merge semantics. See {@link copy} for details. */
|
|
1208
|
+
export const MERGE = 1;
|
|
1209
|
+
/** Flag to {@link copy} and {@link clone} causing them to create a shallow copy (instead of the deep copy done by default).*/
|
|
1210
|
+
export const SHALLOW = 2;
|
|
1211
|
+
const COPY_SUBSCRIBE = 32;
|
|
1212
|
+
const COPY_EMIT = 64;
|
|
1520
1213
|
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
map(func: (store: Store) => any): Store {
|
|
1536
|
-
let out = new Store()
|
|
1537
|
-
observe(() => {
|
|
1538
|
-
let t = this.getType()
|
|
1539
|
-
out.set(t==='array' ? [] : (t==='object' ? {} : new Map()))
|
|
1540
|
-
this.onEach((item: Store) => {
|
|
1541
|
-
let value = func(item)
|
|
1542
|
-
if (value !== undefined) {
|
|
1543
|
-
let key = item.index()
|
|
1544
|
-
const ref = out(key)
|
|
1545
|
-
ref.set(value)
|
|
1546
|
-
clean(() => {
|
|
1547
|
-
ref.delete()
|
|
1548
|
-
})
|
|
1549
|
-
}
|
|
1550
|
-
})
|
|
1551
|
-
})
|
|
1552
|
-
return out
|
|
1553
|
-
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Clone an (optionally proxied) object or array.
|
|
1216
|
+
*
|
|
1217
|
+
* @param src The object or array to clone. If it is proxied, `clone` will subscribe to any changes to the (nested) data structure.
|
|
1218
|
+
* @param flags
|
|
1219
|
+
* - {@link SHALLOW}: Performs a shallow clone, meaning that only the top-level array or object will be copied, while object/array values will just be references to the original data in `src`.
|
|
1220
|
+
* @template T - The type of the objects being copied.
|
|
1221
|
+
* @returns A new unproxied array or object (of the same type as `src`), containing a deep (by default) copy of `src`.
|
|
1222
|
+
*/
|
|
1223
|
+
export function clone<T extends object>(src: T, flags: number = 0): T {
|
|
1224
|
+
const dst = Object.create(Object.getPrototypeOf(src)) as T;
|
|
1225
|
+
copyRecurse(dst, src, flags);
|
|
1226
|
+
return dst;
|
|
1227
|
+
}
|
|
1554
1228
|
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1229
|
+
function copyRecurse(dst: any, src: any, flags: number) {
|
|
1230
|
+
// We never want to subscribe to reads we do to the target (to find changes). So we'll
|
|
1231
|
+
// take the unproxied version and `emit` updates ourselve.
|
|
1232
|
+
let unproxied = dst[TARGET_SYMBOL];
|
|
1233
|
+
if (unproxied) {
|
|
1234
|
+
dst = unproxied;
|
|
1235
|
+
flags |= COPY_EMIT;
|
|
1236
|
+
}
|
|
1237
|
+
// For performance, we'll work on the unproxied `src` and manually subscribe to changes.
|
|
1238
|
+
unproxied = src[TARGET_SYMBOL];
|
|
1239
|
+
if (unproxied) {
|
|
1240
|
+
src = unproxied;
|
|
1241
|
+
// If we're not in peek mode, we'll manually subscribe to all source reads.
|
|
1242
|
+
if (currentScope !== ROOT_SCOPE && !peeking) flags |= COPY_SUBSCRIBE;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
if (flags©_SUBSCRIBE) subscribe(src, ANY_SYMBOL);
|
|
1246
|
+
if (src instanceof Array) {
|
|
1247
|
+
if (!(dst instanceof Array)) throw new Error("Cannot copy array into object");
|
|
1248
|
+
const dstLen = dst.length;
|
|
1249
|
+
const srcLen = src.length;
|
|
1250
|
+
for(let i=0; i<srcLen; i++) {
|
|
1251
|
+
copyValue(dst, src, i, flags)
|
|
1252
|
+
}
|
|
1253
|
+
// Leaving additional values in the old array doesn't make sense
|
|
1254
|
+
if (srcLen !== dstLen) {
|
|
1255
|
+
if (flags©_EMIT) {
|
|
1256
|
+
for(let i=srcLen; i<dstLen; i++) {
|
|
1257
|
+
const old = dst[i];
|
|
1258
|
+
dst[i] = undefined;
|
|
1259
|
+
emit(dst, i, undefined, old);
|
|
1581
1260
|
}
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
const ref = out(key)
|
|
1585
|
-
ref.set(value)
|
|
1586
|
-
refs.push(ref)
|
|
1587
|
-
})
|
|
1261
|
+
dst.length = srcLen;
|
|
1262
|
+
emit(dst, 'length', srcLen, dstLen);
|
|
1588
1263
|
} else {
|
|
1589
|
-
|
|
1264
|
+
dst.length = srcLen;
|
|
1590
1265
|
}
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1266
|
+
}
|
|
1267
|
+
} else {
|
|
1268
|
+
for(let k in src) {
|
|
1269
|
+
copyValue(dst, src, k, flags);
|
|
1270
|
+
}
|
|
1271
|
+
if (!(flags & MERGE)) {
|
|
1272
|
+
for(let k in dst) {
|
|
1273
|
+
if (!(k in src)) {
|
|
1274
|
+
const old = dst[k];
|
|
1275
|
+
delete dst[k];
|
|
1276
|
+
if (flags©_EMIT && old !== undefined) {
|
|
1277
|
+
emit(dst, k, undefined, old);
|
|
1595
1278
|
}
|
|
1596
|
-
}
|
|
1279
|
+
}
|
|
1597
1280
|
}
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1600
|
-
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1601
1284
|
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
} else {
|
|
1619
|
-
$({text: JSON.stringify(this.get())})
|
|
1285
|
+
function copyValue(dst: any, src: any, index: any, flags: number) {
|
|
1286
|
+
let dstValue = dst[index];
|
|
1287
|
+
let srcValue = src[index];
|
|
1288
|
+
if (srcValue !== dstValue) {
|
|
1289
|
+
if (srcValue && dstValue && typeof srcValue === 'object' && typeof dstValue === 'object' && (srcValue.constructor === dstValue.constructor || (flags&MERGE && dstValue instanceof Array))) {
|
|
1290
|
+
copyRecurse(dstValue, srcValue, flags);
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (!(flags&SHALLOW) && srcValue && typeof srcValue === 'object') {
|
|
1295
|
+
// Create an empty object of the same type
|
|
1296
|
+
let copy = Object.create(Object.getPrototypeOf(srcValue));
|
|
1297
|
+
// Copy all properties to it. This doesn't need to emit anything
|
|
1298
|
+
// and MERGE does not apply as this is a new branch.
|
|
1299
|
+
copyRecurse(copy, srcValue, 0);
|
|
1300
|
+
srcValue = copy;
|
|
1620
1301
|
}
|
|
1621
|
-
|
|
1302
|
+
const old = dst[index];
|
|
1303
|
+
if (flags&MERGE && srcValue == null) delete dst[index];
|
|
1304
|
+
else dst[index] = srcValue;
|
|
1305
|
+
if (flags©_EMIT) emit(dst, index, srcValue, old)
|
|
1622
1306
|
}
|
|
1623
1307
|
}
|
|
1624
1308
|
|
|
1625
1309
|
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
function destroyWithClass(element: Element, cls: string) {
|
|
1630
|
-
element.classList.add(cls)
|
|
1631
|
-
setTimeout(() => element.remove(), 2000)
|
|
1310
|
+
interface RefTarget {
|
|
1311
|
+
proxy: TargetType
|
|
1312
|
+
index: any
|
|
1632
1313
|
}
|
|
1314
|
+
const refHandler: ProxyHandler<RefTarget> = {
|
|
1315
|
+
get(target: RefTarget, prop: any) {
|
|
1316
|
+
if (prop===TARGET_SYMBOL) {
|
|
1317
|
+
// Create a ref to the unproxied version of the target
|
|
1318
|
+
return ref(unproxy(target.proxy), target.index);
|
|
1319
|
+
}
|
|
1320
|
+
if (prop==="value") {
|
|
1321
|
+
return (target.proxy as any)[target.index];
|
|
1322
|
+
}
|
|
1323
|
+
},
|
|
1324
|
+
set(target: any, prop: any, value: any) {
|
|
1325
|
+
if (prop==="value") {
|
|
1326
|
+
(target.proxy as any)[target.index] = value;
|
|
1327
|
+
return true;
|
|
1328
|
+
}
|
|
1329
|
+
return false;
|
|
1330
|
+
},
|
|
1331
|
+
};
|
|
1633
1332
|
|
|
1634
1333
|
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1334
|
+
/**
|
|
1335
|
+
* Creates a reactive reference (`{ value: T }`-like object) to a specific value
|
|
1336
|
+
* within a proxied object or array.
|
|
1337
|
+
*
|
|
1338
|
+
* This is primarily used for the `bind` property in {@link $} to create two-way data bindings
|
|
1339
|
+
* with form elements, and for passing a reactive property to any of the {@link $} key-value pairs.
|
|
1340
|
+
*
|
|
1341
|
+
* Reading `ref.value` accesses the property from the underlying proxy (and subscribes the current scope).
|
|
1342
|
+
* Assigning to `ref.value` updates the property in the underlying proxy (triggering reactive updates).
|
|
1343
|
+
*
|
|
1344
|
+
* @param target - The reactive proxy (created by {@link proxy}) containing the target property.
|
|
1345
|
+
* @param index - The key (for objects) or index (for arrays) of the property to reference.
|
|
1346
|
+
* @returns A reference object with a `value` property linked to the specified proxy property.
|
|
1347
|
+
*
|
|
1348
|
+
* @example
|
|
1349
|
+
* ```javascript
|
|
1350
|
+
* const formData = proxy({ color: 'orange', velocity: 42 });
|
|
1351
|
+
*
|
|
1352
|
+
* // Usage with `bind`
|
|
1353
|
+
* $('input', {
|
|
1354
|
+
* type: 'text',
|
|
1355
|
+
* // Creates a two-way binding between the input's value and formData.username
|
|
1356
|
+
* bind: ref(formData, 'color')
|
|
1357
|
+
* });
|
|
1358
|
+
*
|
|
1359
|
+
* // Usage as a dynamic property, causes a TextNode with just the name to be created and live-updated
|
|
1360
|
+
* $('p:Selected color: ', {
|
|
1361
|
+
* text: ref(formData, 'color'),
|
|
1362
|
+
* $color: ref(formData, 'color')
|
|
1363
|
+
* });
|
|
1364
|
+
*
|
|
1365
|
+
* // Changes are actually stored in formData - this causes logs like `{color: "Blue", velocity 42}`
|
|
1366
|
+
* $(() => console.log(formData))
|
|
1367
|
+
* ```
|
|
1368
|
+
*/
|
|
1369
|
+
export function ref<T extends TargetType, K extends keyof T>(target: T, index: K): ValueRef<T[K]> {
|
|
1370
|
+
return new Proxy({proxy: target, index}, refHandler) as any as ValueRef<T[K]>;
|
|
1641
1371
|
}
|
|
1642
1372
|
|
|
1643
1373
|
|
|
1644
|
-
function
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
let
|
|
1649
|
-
let
|
|
1650
|
-
let type = el.getAttribute('type')
|
|
1651
|
-
let value = store.peek()
|
|
1374
|
+
function applyBind(_el: Element, target: any) {
|
|
1375
|
+
const el = _el as HTMLInputElement;
|
|
1376
|
+
let onProxyChange: () => void;
|
|
1377
|
+
let onInputChange: () => void;
|
|
1378
|
+
let type = el.getAttribute('type');
|
|
1379
|
+
let value = unproxy(target).value;
|
|
1652
1380
|
if (type === 'checkbox') {
|
|
1653
|
-
if (value === undefined)
|
|
1654
|
-
|
|
1655
|
-
onInputChange = () =>
|
|
1381
|
+
if (value === undefined) target.value = el.checked;
|
|
1382
|
+
onProxyChange = () => el.checked = target.value;
|
|
1383
|
+
onInputChange = () => target.value = el.checked;
|
|
1656
1384
|
} else if (type === 'radio') {
|
|
1657
|
-
if (value === undefined && el.checked)
|
|
1658
|
-
|
|
1385
|
+
if (value === undefined && el.checked) target.value = el.value;
|
|
1386
|
+
onProxyChange = () => el.checked = (target.value === el.value);
|
|
1659
1387
|
onInputChange = () => {
|
|
1660
|
-
if (el.checked)
|
|
1388
|
+
if (el.checked) target.value = el.value;
|
|
1661
1389
|
}
|
|
1662
1390
|
} else {
|
|
1663
|
-
onInputChange = () =>
|
|
1664
|
-
if (value === undefined) onInputChange()
|
|
1665
|
-
|
|
1666
|
-
if (el.value !== value) el.value = value
|
|
1667
|
-
}
|
|
1391
|
+
onInputChange = () => target.value = type==='number' || type==='range' ? (el.value==='' ? null : +el.value) : el.value;
|
|
1392
|
+
if (value === undefined) onInputChange();
|
|
1393
|
+
onProxyChange = () => el.value = target.value
|
|
1668
1394
|
}
|
|
1669
|
-
observe(
|
|
1670
|
-
|
|
1671
|
-
})
|
|
1672
|
-
el.addEventListener('input', onInputChange)
|
|
1395
|
+
observe(onProxyChange);
|
|
1396
|
+
el.addEventListener('input', onInputChange);
|
|
1673
1397
|
clean(() => {
|
|
1674
|
-
el.removeEventListener('input', onInputChange)
|
|
1675
|
-
})
|
|
1398
|
+
el.removeEventListener('input', onInputChange);
|
|
1399
|
+
});
|
|
1676
1400
|
}
|
|
1677
1401
|
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
if (
|
|
1402
|
+
const SPECIAL_PROPS: {[key: string]: (value: any) => void} = {
|
|
1403
|
+
create: function(value: any) {
|
|
1404
|
+
const el = currentScope.parentElement;
|
|
1405
|
+
if (currentScope !== topRedrawScope) return;
|
|
1682
1406
|
if (typeof value === 'function') {
|
|
1683
|
-
value(el)
|
|
1407
|
+
value(el);
|
|
1684
1408
|
} else {
|
|
1685
|
-
|
|
1686
|
-
(
|
|
1687
|
-
|
|
1688
|
-
(el as HTMLElement).offsetHeight;
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
})()
|
|
1409
|
+
const classes = value.split('.').filter((c: any)=>c);
|
|
1410
|
+
el.classList.add(...classes);
|
|
1411
|
+
(async function(){ // attempt to prevent layout trashing
|
|
1412
|
+
(el as HTMLElement).offsetHeight; // trigger layout
|
|
1413
|
+
el.classList.remove(...classes);
|
|
1414
|
+
})();
|
|
1692
1415
|
}
|
|
1693
1416
|
},
|
|
1694
|
-
destroy: function(
|
|
1695
|
-
|
|
1417
|
+
destroy: function(value: any) {
|
|
1418
|
+
const el = currentScope.parentElement;
|
|
1419
|
+
onDestroyMap.set(el, value);
|
|
1696
1420
|
},
|
|
1697
|
-
html: function(
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
tmpParent.
|
|
1701
|
-
while(tmpParent.firstChild) addLeafNode(deepEl, tmpParent.firstChild)
|
|
1421
|
+
html: function(value: any) {
|
|
1422
|
+
let tmpParent = document.createElement(currentScope.parentElement.tagName);
|
|
1423
|
+
tmpParent.innerHTML = ''+value;
|
|
1424
|
+
while(tmpParent.firstChild) addNode(tmpParent.firstChild);
|
|
1702
1425
|
},
|
|
1703
|
-
text: function(
|
|
1704
|
-
|
|
1426
|
+
text: function(value: any) {
|
|
1427
|
+
addNode(document.createTextNode(value));
|
|
1705
1428
|
},
|
|
1706
|
-
element: function(
|
|
1707
|
-
if (value
|
|
1708
|
-
|
|
1709
|
-
addLeafNode(deepEl, value)
|
|
1429
|
+
element: function(value: any) {
|
|
1430
|
+
if (!(value instanceof Node)) throw new Error(`Unexpected element-argument: ${JSON.parse(value)}`);
|
|
1431
|
+
addNode(value);
|
|
1710
1432
|
},
|
|
1711
1433
|
}
|
|
1712
1434
|
|
|
1713
1435
|
|
|
1714
1436
|
|
|
1715
1437
|
/**
|
|
1716
|
-
*
|
|
1717
|
-
*
|
|
1718
|
-
*
|
|
1719
|
-
*
|
|
1720
|
-
*
|
|
1721
|
-
*
|
|
1722
|
-
*
|
|
1723
|
-
*
|
|
1724
|
-
*
|
|
1725
|
-
*
|
|
1726
|
-
*
|
|
1727
|
-
*
|
|
1728
|
-
*
|
|
1729
|
-
*
|
|
1730
|
-
*
|
|
1731
|
-
*
|
|
1732
|
-
*
|
|
1733
|
-
*
|
|
1734
|
-
*
|
|
1735
|
-
*
|
|
1736
|
-
*
|
|
1737
|
-
*
|
|
1738
|
-
*
|
|
1739
|
-
*
|
|
1740
|
-
*
|
|
1741
|
-
*
|
|
1742
|
-
*
|
|
1743
|
-
*
|
|
1744
|
-
*
|
|
1745
|
-
*
|
|
1746
|
-
*
|
|
1747
|
-
*
|
|
1748
|
-
*
|
|
1749
|
-
*
|
|
1750
|
-
*
|
|
1751
|
-
*
|
|
1752
|
-
*
|
|
1753
|
-
*
|
|
1754
|
-
*
|
|
1755
|
-
*
|
|
1756
|
-
*
|
|
1757
|
-
*
|
|
1758
|
-
*
|
|
1759
|
-
* // Content
|
|
1760
|
-
* $('div', {html: '<b>Bold</b>'}) // Set innerHTML
|
|
1761
|
-
* $('div', {text: 'Plain text'}) // Add text node
|
|
1762
|
-
* const myElement = document.createElement('video')
|
|
1763
|
-
* $('div', {element: myElement}) // Add existing DOM element
|
|
1764
|
-
*
|
|
1765
|
-
* // Regular attributes (everything else)
|
|
1766
|
-
* $('div', {title: 'Info'}) // el.setAttribute('title', 'info')
|
|
1438
|
+
* The core function for building reactive user interfaces in Aberdeen. It creates and inserts new DOM elements
|
|
1439
|
+
* and sets attributes/properties/event listeners on DOM elements. It does so in a reactive way, meaning that
|
|
1440
|
+
* changes will be (mostly) undone when the current *scope* is destroyed or will be re-execute.
|
|
1441
|
+
*
|
|
1442
|
+
* @param {...(string | function | object | false | undefined | null)} args - Any number of arguments can be given. How they're interpreted depends on their types:
|
|
1443
|
+
*
|
|
1444
|
+
* - `string`: Strings can be used to create and insert new elements, set classnames for the *current* element, and add text to the current element.
|
|
1445
|
+
* The format of a string is: **tag**? (`.` **class**)* (':' **text**)?
|
|
1446
|
+
* meaning it consists of...
|
|
1447
|
+
* - An optional HTML **tag**, something like `h1`. If present, a DOM element of that tag is created, and that element will be the *current* element for the rest of this `$` function execution.
|
|
1448
|
+
* - Any number of CSS classes prefixed by `.` characters. These classes will be added to the *current* element.
|
|
1449
|
+
* - Optional content **text** prefixed by a `:` character, ranging til the end of the string. This will be added as a TextNode to the *current* element.
|
|
1450
|
+
* - `function`: When a function (without argument nor a return value) is passed in, it will be reactively executed in its own observe scope, preserving the *current element*. So any `$()` invocations within this function will create DOM elements with our *current* element as parent. If the function reads observable data, and that data is changed later on, the function we re-execute (after side effects, such as DOM modifications through `$`, have been cleaned - see also {@link clean}).
|
|
1451
|
+
* - `object`: When an object is passed in, its key-value pairs are used to modify the *current* element in the following ways...
|
|
1452
|
+
* - `{<attrName>: any}`: The common case is setting the value as an HTML attribute named key. So `{placeholder: "Your name"}` would add `placeholder="Your name"` to the current HTML element.
|
|
1453
|
+
* - `{<propName>: boolean}` or `{value: any}` or `{selectedIndex: number}`: If the value is a boolean, or if the key is `value` or `selectedIndex`, it is set on the `current` element as a DOM property instead of an HTML attribute. For example `{checked: true}` would do `el.checked = true` for the *current* element.
|
|
1454
|
+
* - `{".class": boolean}`: If the key starts with a `.` character, its either added to or removed from the *current* element as a CSS class, based on the truthiness of the value. So `{".hidden": hide}` would toggle the `hidden` CSS class.
|
|
1455
|
+
* - `{<eventName>: function}`: If the value is a `function` it is set as an event listener for the event with the name given by the key. For example: `{click: myClickHandler}`.
|
|
1456
|
+
* - `{$<styleProp>: value}`: If the key starts with a `$` character, set a CSS style property with the name of the rest of the key to the given value. Example: `{$backgroundColor: 'red'}`.
|
|
1457
|
+
* - `{create: string}`: Add the value string as a CSS class to the *current* element, *after* the browser has finished doing a layout pass. This behavior only triggers when the scope setting the `create` is the top-level scope being (re-)run. This allows for creation transitions, without triggering the transitions for deeply nested elements being drawn as part of a larger component. The string may also contain multiple dot-separated CSS classes, such as `.fade.grow`.
|
|
1458
|
+
* - `{destroy: string}`: When the *current* element is a top-level element to be removed (due to reactivity cleanup), actual removal from the DOM is delayed by 2 seconds, and in the mean time the value string is added as a CSS class to the element, allowing for a deletion transition. The string may also contain multiple dot-separated CSS classes, such as `.fade.shrink`.
|
|
1459
|
+
* - `{create: function}` and `{destroy: function}`: The function is invoked when the *current* element is the top-level element being created/destroyed. It can be used for more involved creation/deletion animations. In case of `destroy`, the function is responsible for actually removing the element from the DOM (eventually). See `transitions.ts` in the Aberdeen source code for some examples.
|
|
1460
|
+
* - `{bind: <obsValue>}`: Create a two-way binding element between the `value` property of the given observable (proxy) variable, and the *current* input element (`<input>`, `<select>` or `<textarea>`). This is often used together with {@link ref}, in order to use properties other than `.value`.
|
|
1461
|
+
* - `{<any>: <obsvalue>}`: Create a new observe scope and read the `value` property of the given observable (proxy) variable from within it, and apply the contained value using any of the other rules in this list. Example:
|
|
1462
|
+
* ```typescript
|
|
1463
|
+
* const myColor = proxy('red');
|
|
1464
|
+
* $('p:Test', {$color: myColor, click: () => myColor.value = 'yellow'})
|
|
1465
|
+
* // Clicking the text will cause it to change color without recreating the <p> itself
|
|
1466
|
+
* ```
|
|
1467
|
+
* This is often used together with {@link ref}, in order to use properties other than `.value`.
|
|
1468
|
+
* - `{text: string|number}`: Add the value as a `TextNode` to the *current* element.
|
|
1469
|
+
* - `{html: string}`: Add the value as HTML to the *current* element. This should only be used in exceptional situations. And of course, beware of XSS.
|
|
1470
|
+
* - `{element: Node}`: Add a pre-existing HTML `Node` to the *current* element.
|
|
1471
|
+
*
|
|
1472
|
+
*
|
|
1473
|
+
* @example Create Element
|
|
1474
|
+
* ```typescript
|
|
1475
|
+
* $('button.secondary.outline:Submit', {
|
|
1476
|
+
* disabled: true,
|
|
1477
|
+
* click: () => console.log('Clicked!'),
|
|
1478
|
+
* $color: 'red'
|
|
1479
|
+
* });
|
|
1767
1480
|
* ```
|
|
1768
|
-
*
|
|
1769
|
-
*
|
|
1770
|
-
*
|
|
1771
|
-
*
|
|
1772
|
-
*
|
|
1773
|
-
*
|
|
1774
|
-
*
|
|
1775
|
-
*
|
|
1776
|
-
*
|
|
1777
|
-
* $color: colorStore, // Reactive style
|
|
1778
|
-
* text: textStore // Reactive text
|
|
1779
|
-
* })
|
|
1481
|
+
*
|
|
1482
|
+
* @example Nested Elements & Reactive Scope
|
|
1483
|
+
* ```typescript
|
|
1484
|
+
* const state = proxy({ count: 0 });
|
|
1485
|
+
* $('div', () => { // Outer element
|
|
1486
|
+
* // This scope re-renders when state.count changes
|
|
1487
|
+
* $('p:Count is ${state.count}`);
|
|
1488
|
+
* $('button:Increment', { click: () => state.count++ });
|
|
1489
|
+
* });
|
|
1780
1490
|
* ```
|
|
1781
|
-
*
|
|
1782
|
-
*
|
|
1783
|
-
*
|
|
1784
|
-
*
|
|
1785
|
-
*
|
|
1786
|
-
*
|
|
1787
|
-
*
|
|
1491
|
+
*
|
|
1492
|
+
* @example Two-way Binding
|
|
1493
|
+
* ```typescript
|
|
1494
|
+
* const user = proxy({ name: '' });
|
|
1495
|
+
* $('input', { placeholder: 'Name', bind: ref(user, 'name') });
|
|
1496
|
+
* $('h3', () => { // Reactive scope
|
|
1497
|
+
* $(`:Hello ${user.name || 'stranger'}`);
|
|
1498
|
+
* });
|
|
1788
1499
|
* ```
|
|
1789
|
-
*
|
|
1790
|
-
*
|
|
1791
|
-
*
|
|
1792
|
-
*
|
|
1793
|
-
*
|
|
1794
|
-
*
|
|
1795
|
-
*
|
|
1796
|
-
*
|
|
1797
|
-
*
|
|
1500
|
+
*
|
|
1501
|
+
* @example Conditional Rendering
|
|
1502
|
+
* ```typescript
|
|
1503
|
+
* const show = proxy(false);
|
|
1504
|
+
* $('button', { click: () => show.value = !show.value }, () => $(show.value ? ':Hide' : ':Show'));
|
|
1505
|
+
* $(() => { // Reactive scope
|
|
1506
|
+
* if (show.value) {
|
|
1507
|
+
* $('p:Details are visible!');
|
|
1508
|
+
* }
|
|
1509
|
+
* });
|
|
1798
1510
|
* ```
|
|
1799
|
-
* When *only* a function is given, `$` behaves exactly like {@link Store.observe},
|
|
1800
|
-
* except that it will only work when we're inside a `mount`.
|
|
1801
|
-
*
|
|
1802
|
-
* @throws {ScopeError} If called outside an observable scope.
|
|
1803
|
-
* @throws {Error} If invalid arguments are provided.
|
|
1804
1511
|
*/
|
|
1805
1512
|
|
|
1806
|
-
export function $(...args: (string | (() => void) | false | null | undefined | {[key: string]: any})[]) {
|
|
1807
|
-
if (!currentScope || !currentScope._parentElement) throw new ScopeError(true)
|
|
1808
1513
|
|
|
1809
|
-
|
|
1514
|
+
export function $(...args: (string | null | undefined | false | (() => void) | Record<string,any>)[]): void {
|
|
1515
|
+
let savedCurrentScope;
|
|
1516
|
+
let err;
|
|
1810
1517
|
|
|
1811
1518
|
for(let arg of args) {
|
|
1812
|
-
if (arg == null || arg === false) continue
|
|
1519
|
+
if (arg == null || arg === false) continue;
|
|
1813
1520
|
if (typeof arg === 'string') {
|
|
1814
|
-
let text, classes
|
|
1815
|
-
const textPos = arg.indexOf(':')
|
|
1521
|
+
let text, classes: undefined | string;
|
|
1522
|
+
const textPos = arg.indexOf(':');
|
|
1816
1523
|
if (textPos >= 0) {
|
|
1817
|
-
text = arg.substring(textPos+1)
|
|
1818
|
-
|
|
1819
|
-
addLeafNode(deepEl, document.createTextNode(text))
|
|
1820
|
-
continue
|
|
1821
|
-
}
|
|
1822
|
-
arg = arg.substring(0,textPos)
|
|
1524
|
+
text = arg.substring(textPos+1);
|
|
1525
|
+
arg = arg.substring(0,textPos);
|
|
1823
1526
|
}
|
|
1824
|
-
const classPos = arg.indexOf('.')
|
|
1527
|
+
const classPos = arg.indexOf('.');
|
|
1825
1528
|
if (classPos >= 0) {
|
|
1826
|
-
classes = arg.substring(classPos+1)
|
|
1827
|
-
arg = arg.substring(0, classPos)
|
|
1529
|
+
classes = arg.substring(classPos+1);
|
|
1530
|
+
arg = arg.substring(0, classPos);
|
|
1531
|
+
}
|
|
1532
|
+
if (arg === '') { // Add text or classes to parent
|
|
1533
|
+
if (text) addNode(document.createTextNode(text));
|
|
1534
|
+
if (classes) {
|
|
1535
|
+
const el = currentScope.parentElement;
|
|
1536
|
+
el.classList.add(...classes.split('.'));
|
|
1537
|
+
if (!savedCurrentScope) {
|
|
1538
|
+
clean(() => el.classList.remove(...classes.split('.')));
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
} else if (arg.indexOf(' ') >= 0) {
|
|
1542
|
+
err = `Tag '${arg}' cannot contain space`;
|
|
1543
|
+
break;
|
|
1544
|
+
} else {
|
|
1545
|
+
const el = document.createElement(arg);
|
|
1546
|
+
if (classes) el.className = classes.replaceAll('.', ' ');
|
|
1547
|
+
if (text) el.textContent = text;
|
|
1548
|
+
addNode(el);
|
|
1549
|
+
if (!savedCurrentScope) {
|
|
1550
|
+
savedCurrentScope = currentScope;
|
|
1551
|
+
}
|
|
1552
|
+
let newScope = new ChainedScope(el, true);
|
|
1553
|
+
newScope.lastChild = el.lastChild || undefined;
|
|
1554
|
+
if (topRedrawScope === currentScope) topRedrawScope = newScope;
|
|
1555
|
+
currentScope = newScope;
|
|
1828
1556
|
}
|
|
1829
|
-
if (arg.indexOf(' ') >= 0) throw new Error(`Tag '${arg}' cannot contain space`)
|
|
1830
|
-
const el = document.createElement(arg || 'div')
|
|
1831
|
-
if (classes) el.className = classes
|
|
1832
|
-
if (text) el.textContent = text
|
|
1833
|
-
addLeafNode(deepEl, el)
|
|
1834
|
-
deepEl = el
|
|
1835
1557
|
}
|
|
1836
1558
|
else if (typeof arg === 'object') {
|
|
1837
|
-
if (arg.constructor !== Object)
|
|
1559
|
+
if (arg.constructor !== Object) {
|
|
1560
|
+
err = `Unexpected argument: ${arg}`;
|
|
1561
|
+
break;
|
|
1562
|
+
}
|
|
1838
1563
|
for(const key in arg) {
|
|
1839
|
-
const val = arg[key]
|
|
1840
|
-
|
|
1841
|
-
applyBinding(deepEl, key, val)
|
|
1842
|
-
} else if (val instanceof Store) {
|
|
1843
|
-
let childScope = new SetArgScope(deepEl, deepEl.lastChild as Node, currentScope!._queueOrder+1, key, val)
|
|
1844
|
-
childScope._install()
|
|
1845
|
-
} else {
|
|
1846
|
-
applyArg(deepEl, key, val)
|
|
1847
|
-
}
|
|
1564
|
+
const val = arg[key];
|
|
1565
|
+
applyArg(key, val);
|
|
1848
1566
|
}
|
|
1849
1567
|
} else if (typeof arg === 'function') {
|
|
1850
|
-
|
|
1851
|
-
_mount(undefined, args[0] as any, SimpleScope)
|
|
1852
|
-
} else { // new scope for a new node without any scope attached yet
|
|
1853
|
-
let childScope = new SimpleScope(deepEl, deepEl.lastChild as Node, currentScope._queueOrder+1, arg)
|
|
1854
|
-
childScope._install()
|
|
1855
|
-
}
|
|
1568
|
+
new RegularScope(currentScope.parentElement, arg);
|
|
1856
1569
|
} else {
|
|
1857
|
-
|
|
1570
|
+
err = `Unexpected argument: ${arg}`;
|
|
1571
|
+
break;
|
|
1858
1572
|
}
|
|
1859
1573
|
}
|
|
1574
|
+
if (savedCurrentScope) {
|
|
1575
|
+
currentScope = savedCurrentScope;
|
|
1576
|
+
}
|
|
1577
|
+
if (err) throw new Error(err);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
let cssCount = 0;
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* Inserts CSS rules into the document, optionally scoping them with a unique class name.
|
|
1584
|
+
*
|
|
1585
|
+
* Takes a JavaScript object representation of CSS rules. camelCased property keys are
|
|
1586
|
+
* converted to kebab-case (e.g., `fontSize` becomes `font-size`).
|
|
1587
|
+
*
|
|
1588
|
+
* @param style - An object where keys are CSS selectors (or camelCased properties) and values are
|
|
1589
|
+
* CSS properties or nested rule objects.
|
|
1590
|
+
* - Selectors are usually combined as a descendant-relationship (meaning just a space character) with their parent selector.
|
|
1591
|
+
* - In case a selector contains a `&`, that character will be replaced by the parent selector.
|
|
1592
|
+
* - Selectors will be split on `,` characters, each combining with the parent selector with *or* semantics.
|
|
1593
|
+
* - Selector starting with `'@'` define at-rules like media queries. They may be nested within regular selectors.
|
|
1594
|
+
* @param global - If `true`, styles are inserted globally without prefixing.
|
|
1595
|
+
* If `false` (default), all selectors are prefixed with a unique generated
|
|
1596
|
+
* class name (e.g., `.AbdStl1`) to scope the styles.
|
|
1597
|
+
* @returns The unique class name prefix used for scoping (e.g., `.AbdStl1`), or an empty string
|
|
1598
|
+
* if `global` was `true`. Use this prefix with {@link $} to apply the styles.
|
|
1599
|
+
*
|
|
1600
|
+
* @example Scoped Styles
|
|
1601
|
+
* ```typescript
|
|
1602
|
+
* const scopeClass = insertCss({
|
|
1603
|
+
* color: 'red',
|
|
1604
|
+
* padding: '10px',
|
|
1605
|
+
* '&:hover': { // Use '&' for the root scoped selector
|
|
1606
|
+
* backgroundColor: '#535'
|
|
1607
|
+
* },
|
|
1608
|
+
* '.child-element': { // Nested selector
|
|
1609
|
+
* fontWeight: 'bold'
|
|
1610
|
+
* },
|
|
1611
|
+
* '@media (max-width: 600px)': {
|
|
1612
|
+
* padding: '5px'
|
|
1613
|
+
* }
|
|
1614
|
+
* });
|
|
1615
|
+
* // scopeClass might be ".AbdStl1"
|
|
1616
|
+
*
|
|
1617
|
+
* // Apply the styles
|
|
1618
|
+
* $(scopeClass, () => { // Add class to the div
|
|
1619
|
+
* $(`:Scoped content`);
|
|
1620
|
+
* $('div.child-element:Child'); // .AbdStl1 .child-element rule applies
|
|
1621
|
+
* });
|
|
1622
|
+
* ```
|
|
1623
|
+
*
|
|
1624
|
+
* @example Global Styles
|
|
1625
|
+
* ```typescript
|
|
1626
|
+
* insertCss({
|
|
1627
|
+
* '*': {
|
|
1628
|
+
* fontFamily: 'monospace',
|
|
1629
|
+
* },
|
|
1630
|
+
* 'a': {
|
|
1631
|
+
* textDecoration: 'none',
|
|
1632
|
+
* color: "#107ab0",
|
|
1633
|
+
* }
|
|
1634
|
+
* }, true); // Pass true for global
|
|
1635
|
+
*
|
|
1636
|
+
* $('a:Styled link');
|
|
1637
|
+
* ```
|
|
1638
|
+
*/
|
|
1639
|
+
export function insertCss(style: object, global: boolean = false): string {
|
|
1640
|
+
const prefix = global ? "" : ".AbdStl" + ++cssCount;
|
|
1641
|
+
let css = styleToCss(style, prefix);
|
|
1642
|
+
if (css) $('style:'+css);
|
|
1643
|
+
return prefix;
|
|
1860
1644
|
}
|
|
1861
1645
|
|
|
1646
|
+
function styleToCss(style: object, prefix: string): string {
|
|
1647
|
+
let props = '';
|
|
1648
|
+
let rules = '';
|
|
1649
|
+
for(const kOr in style) {
|
|
1650
|
+
const v = (style as any)[kOr];
|
|
1651
|
+
for(const k of kOr.split(/, ?/g)) {
|
|
1652
|
+
if (v && typeof v === 'object') {
|
|
1653
|
+
if (k.startsWith('@')) { // media queries
|
|
1654
|
+
rules += k + '{\n' + styleToCss(v, prefix) + '}\n';
|
|
1655
|
+
} else {
|
|
1656
|
+
rules += styleToCss(v, k.includes('&') ? k.replace(/&/g, prefix) : prefix+' '+k);
|
|
1657
|
+
}
|
|
1658
|
+
} else {
|
|
1659
|
+
props += k.replace(/[A-Z]/g, letter => '-'+letter.toLowerCase()) +":"+v+";";
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
if (props) rules = (prefix.trimStart() || '*') + '{'+props+'}\n' + rules;
|
|
1664
|
+
return rules;
|
|
1665
|
+
}
|
|
1862
1666
|
|
|
1863
|
-
function applyArg(
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
if (
|
|
1867
|
-
|
|
1667
|
+
function applyArg(key: string, value: any) {
|
|
1668
|
+
const el = currentScope.parentElement;
|
|
1669
|
+
if (typeof value === 'object' && value !== null && value[TARGET_SYMBOL]) { // Value is a proxy
|
|
1670
|
+
if (key === 'bind') {
|
|
1671
|
+
applyBind(el, value)
|
|
1672
|
+
} else {
|
|
1673
|
+
new SetArgScope(el, key, value)
|
|
1674
|
+
// SetArgScope will (repeatedly) call `applyArg` again with the actual value
|
|
1675
|
+
}
|
|
1676
|
+
} else if (key[0] === '.') { // CSS class(es)
|
|
1677
|
+
const classes = key.substring(1).split('.');
|
|
1678
|
+
if (value) el.classList.add(...classes);
|
|
1679
|
+
else el.classList.remove(...classes);
|
|
1868
1680
|
} else if (key[0] === '$') { // Style
|
|
1869
1681
|
const name = key.substring(1);
|
|
1870
|
-
if (value==null || value===false) (
|
|
1871
|
-
else (
|
|
1682
|
+
if (value==null || value===false) (el as any).style[name] = ''
|
|
1683
|
+
else (el as any).style[name] = ''+value;
|
|
1684
|
+
} else if (value == null) { // Value left empty
|
|
1685
|
+
// Do nothing
|
|
1872
1686
|
} else if (key in SPECIAL_PROPS) { // Special property
|
|
1873
|
-
SPECIAL_PROPS[key](
|
|
1687
|
+
SPECIAL_PROPS[key](value);
|
|
1874
1688
|
} else if (typeof value === 'function') { // Event listener
|
|
1875
|
-
|
|
1876
|
-
clean(() =>
|
|
1689
|
+
el.addEventListener(key, value);
|
|
1690
|
+
clean(() => el.removeEventListener(key, value));
|
|
1877
1691
|
} else if (value===true || value===false || key==='value' || key==='selectedIndex') { // DOM property
|
|
1878
|
-
(
|
|
1692
|
+
(el as any)[key] = value;
|
|
1879
1693
|
} else { // HTML attribute
|
|
1880
|
-
|
|
1694
|
+
el.setAttribute(key, value);
|
|
1881
1695
|
}
|
|
1882
1696
|
}
|
|
1883
|
-
|
|
1697
|
+
|
|
1884
1698
|
function defaultOnError(error: Error) {
|
|
1885
|
-
console.error('Error while in Aberdeen render:', error)
|
|
1886
|
-
return true
|
|
1699
|
+
console.error('Error while in Aberdeen render:', error);
|
|
1700
|
+
return true;
|
|
1887
1701
|
}
|
|
1888
|
-
let onError: (error: Error) => boolean | undefined = defaultOnError
|
|
1702
|
+
let onError: (error: Error) => boolean | undefined = defaultOnError;
|
|
1889
1703
|
|
|
1890
1704
|
/**
|
|
1891
|
-
*
|
|
1892
|
-
*
|
|
1893
|
-
*
|
|
1894
|
-
*
|
|
1895
|
-
*
|
|
1896
|
-
*
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
1899
|
-
*
|
|
1900
|
-
*
|
|
1901
|
-
*
|
|
1902
|
-
*
|
|
1903
|
-
*
|
|
1904
|
-
*
|
|
1705
|
+
* Sets a custom error handler function for errors that occur asynchronously
|
|
1706
|
+
* within reactive scopes (e.g., during updates triggered by proxy changes in
|
|
1707
|
+
* {@link observe} or {@link $} render functions).
|
|
1708
|
+
*
|
|
1709
|
+
* The default handler logs the error to `console.error` and adds a simple
|
|
1710
|
+
* 'Error' message div to the DOM at the location where the error occurred (if possible).
|
|
1711
|
+
*
|
|
1712
|
+
* Your handler can provide custom logging, UI feedback, or suppress the default
|
|
1713
|
+
* error message.
|
|
1714
|
+
*
|
|
1715
|
+
* @param handler - A function that accepts the `Error` object.
|
|
1716
|
+
* - Return `false` to prevent adding an error message to the DOM.
|
|
1717
|
+
* - Return `true` or `undefined` (or throw) to allow the error messages to be added to the DOM.
|
|
1718
|
+
*
|
|
1719
|
+
* @example Custom Logging and Suppressing Default Message
|
|
1720
|
+
* ```typescript
|
|
1905
1721
|
* setErrorHandler(error => {
|
|
1906
|
-
*
|
|
1907
|
-
*
|
|
1908
|
-
*
|
|
1909
|
-
*
|
|
1910
|
-
*
|
|
1911
|
-
*
|
|
1912
|
-
*
|
|
1913
|
-
*
|
|
1722
|
+
* console.warn('Aberdeen render error:', error.message);
|
|
1723
|
+
* // Log to error reporting service
|
|
1724
|
+
* // myErrorReporter.log(error);
|
|
1725
|
+
*
|
|
1726
|
+
* try {
|
|
1727
|
+
* // Attempt to show a custom message in the UI
|
|
1728
|
+
* $('div.error-display:Oops, something went wrong!');
|
|
1729
|
+
* } catch (e) {
|
|
1730
|
+
* // Ignore errors during error handling itself
|
|
1731
|
+
* }
|
|
1732
|
+
*
|
|
1733
|
+
* return false; // Suppress default console log and DOM error message
|
|
1734
|
+
* });
|
|
1735
|
+
*
|
|
1736
|
+
* // Cause an error within a render scope.
|
|
1737
|
+
* $('div.box', () => {
|
|
1738
|
+
* noSuchFunction();
|
|
1914
1739
|
* })
|
|
1915
1740
|
* ```
|
|
1916
1741
|
*/
|
|
1917
1742
|
export function setErrorHandler(handler?: (error: Error) => boolean | undefined) {
|
|
1918
|
-
onError = handler || defaultOnError
|
|
1743
|
+
onError = handler || defaultOnError;
|
|
1919
1744
|
}
|
|
1920
1745
|
|
|
1921
1746
|
|
|
1922
1747
|
/**
|
|
1923
|
-
*
|
|
1924
|
-
*
|
|
1925
|
-
*
|
|
1926
|
-
*
|
|
1927
|
-
*
|
|
1748
|
+
* Gets the parent DOM `Element` where nodes created by {@link $} would currently be inserted.
|
|
1749
|
+
*
|
|
1750
|
+
* This is context-dependent based on the current reactive scope (e.g., inside a {@link mount}
|
|
1751
|
+
* call or a {@link $} element's render function).
|
|
1752
|
+
*
|
|
1753
|
+
* **Note:** While this provides access to the DOM element, directly manipulating it outside
|
|
1754
|
+
* of Aberdeen's control is generally discouraged. Prefer declarative updates using {@link $}.
|
|
1755
|
+
*
|
|
1756
|
+
* @returns The current parent `Element` for DOM insertion.
|
|
1757
|
+
*
|
|
1758
|
+
* @example Get parent for attaching a third-party library
|
|
1759
|
+
* ```typescript
|
|
1760
|
+
* function thirdPartyLibInit(parentElement) {
|
|
1761
|
+
* parentElement.innerHTML = "This element is managed by a <em>third party</em> lib."
|
|
1762
|
+
* }
|
|
1763
|
+
*
|
|
1764
|
+
* $('div.box', () => {
|
|
1765
|
+
* // Get the div.box element just created
|
|
1766
|
+
* const containerElement = getParentElement();
|
|
1767
|
+
* thirdPartyLibInit(containerElement);
|
|
1768
|
+
* });
|
|
1769
|
+
* ```
|
|
1928
1770
|
*/
|
|
1929
1771
|
export function getParentElement(): Element {
|
|
1930
|
-
|
|
1931
|
-
return currentScope._parentElement
|
|
1772
|
+
return currentScope.parentElement;
|
|
1932
1773
|
}
|
|
1933
1774
|
|
|
1934
1775
|
|
|
1935
1776
|
/**
|
|
1936
|
-
*
|
|
1937
|
-
*
|
|
1938
|
-
*
|
|
1777
|
+
* Registers a cleanup function to be executed just before the current reactive scope
|
|
1778
|
+
* is destroyed or redraws.
|
|
1779
|
+
*
|
|
1780
|
+
* This is useful for releasing resources, removing manual event listeners, or cleaning up
|
|
1781
|
+
* side effects associated with the scope. Cleaners are run in reverse order of registration.
|
|
1782
|
+
*
|
|
1783
|
+
* Scopes are created by functions like {@link observe}, {@link mount}, {@link $} (when given a render function),
|
|
1784
|
+
* and internally by constructs like {@link onEach}.
|
|
1785
|
+
*
|
|
1786
|
+
* @param cleaner - The function to execute during cleanup.
|
|
1787
|
+
*
|
|
1788
|
+
* @example Maintaing a sum for a changing array
|
|
1789
|
+
* ```typescript
|
|
1790
|
+
* const myArray = proxy([3, 5, 10]);
|
|
1791
|
+
* let sum = proxy(0);
|
|
1792
|
+
*
|
|
1793
|
+
* // Show the array items and maintain the sum
|
|
1794
|
+
* onEach(myArray, (item, index) => {
|
|
1795
|
+
* $(`code:${index}→${item}`);
|
|
1796
|
+
* // We'll update sum.value using peek, as += first does a read, but
|
|
1797
|
+
* // we don't want to subscribe.
|
|
1798
|
+
* peek(() => sum.value += item);
|
|
1799
|
+
* // Clean gets called before each rerun for a certain item index
|
|
1800
|
+
* // No need for peek here, as the clean code doesn't run in an
|
|
1801
|
+
* // observe scope.
|
|
1802
|
+
* clean(() => sum.value -= item);
|
|
1803
|
+
* })
|
|
1804
|
+
*
|
|
1805
|
+
* // Show the sum
|
|
1806
|
+
* $('h1', {text: sum});
|
|
1807
|
+
*
|
|
1808
|
+
* // Make random changes to the array
|
|
1809
|
+
* const rnd = () => 0|(Math.random()*20);
|
|
1810
|
+
* setInterval(() => myArray[rnd()] = rnd(), 1000);
|
|
1811
|
+
* ```
|
|
1939
1812
|
*/
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
currentScope.
|
|
1813
|
+
|
|
1814
|
+
export function clean(cleaner: () => void) {
|
|
1815
|
+
currentScope.cleaners.push(cleaner);
|
|
1943
1816
|
}
|
|
1944
1817
|
|
|
1945
1818
|
|
|
1946
1819
|
/**
|
|
1947
|
-
*
|
|
1948
|
-
* during its execution
|
|
1949
|
-
*
|
|
1950
|
-
* no cause the outer function to rerun.
|
|
1820
|
+
* Creates a reactive scope that automatically re-executes the provided function
|
|
1821
|
+
* whenever any proxied data (created by {@link proxy}) read during its last execution changes, storing
|
|
1822
|
+
* its return value in an observable.
|
|
1951
1823
|
*
|
|
1952
|
-
*
|
|
1953
|
-
* @
|
|
1954
|
-
* @
|
|
1824
|
+
* Updates are batched and run asynchronously shortly after the changes occur.
|
|
1825
|
+
* Use {@link clean} to register cleanup logic for the scope.
|
|
1826
|
+
* Use {@link peek} or {@link unproxy} within the function to read proxied data without subscribing to it.
|
|
1827
|
+
*
|
|
1828
|
+
* @param func - The function to execute reactively. Any DOM manipulations should typically
|
|
1829
|
+
* be done using {@link $} within this function. Its return value will be made available as an
|
|
1830
|
+
* observable returned by the `observe()` function.
|
|
1831
|
+
* @returns An observable object, with its `value` property containing whatever the last run of `func` returned.
|
|
1832
|
+
*
|
|
1833
|
+
* @example Observation creating a UI components
|
|
1834
|
+
* ```typescript
|
|
1835
|
+
* const data = proxy({ user: 'Frank', notifications: 42 });
|
|
1836
|
+
*
|
|
1837
|
+
* $('main', () => {
|
|
1838
|
+
* console.log('Welcome');
|
|
1839
|
+
* $('h3:Welcome, ' + data.user); // Reactive text
|
|
1840
|
+
*
|
|
1841
|
+
* observe(() => {
|
|
1842
|
+
* // When data.notifications changes, only this inner scope reruns,
|
|
1843
|
+
* // leaving the `<p>Welcome, ..</p>` untouched.
|
|
1844
|
+
* console.log('Notifications');
|
|
1845
|
+
* $('code.notification-badge:' + data.notifications);
|
|
1846
|
+
* $('a:Notify!', {click: () => data.notifications++});
|
|
1847
|
+
* });
|
|
1848
|
+
* });
|
|
1955
1849
|
* ```
|
|
1956
|
-
*
|
|
1957
|
-
*
|
|
1958
|
-
*
|
|
1850
|
+
*
|
|
1851
|
+
* ***Note*** that the above could just as easily be done using `$(func)` instead of `observe(func)`.
|
|
1852
|
+
*
|
|
1853
|
+
* @example Observation with return value
|
|
1854
|
+
* ```typescript
|
|
1855
|
+
* const counter = proxy(0);
|
|
1856
|
+
* setInterval(() => counter.value++, 1000);
|
|
1857
|
+
* const double = observe(() => counter.value * 2);
|
|
1959
1858
|
*
|
|
1960
|
-
*
|
|
1961
|
-
*
|
|
1859
|
+
* $('h3', () => {
|
|
1860
|
+
* $(`:counter=${counter.value} double=${double.value}`);
|
|
1962
1861
|
* })
|
|
1862
|
+
* ```
|
|
1963
1863
|
*
|
|
1964
|
-
*
|
|
1965
|
-
*
|
|
1966
|
-
* })
|
|
1864
|
+
* @overload
|
|
1865
|
+
* @param func Func without a return value.
|
|
1967
1866
|
*/
|
|
1968
|
-
export function observe(func: () =>
|
|
1969
|
-
return
|
|
1867
|
+
export function observe<T extends (DatumType | void)>(func: () => T): ValueRef<T> {
|
|
1868
|
+
return (new ResultScope<T>(currentScope.parentElement, func)).result;
|
|
1970
1869
|
}
|
|
1971
1870
|
|
|
1972
1871
|
/**
|
|
1973
|
-
*
|
|
1974
|
-
*
|
|
1975
|
-
*
|
|
1976
|
-
*
|
|
1977
|
-
*
|
|
1978
|
-
*
|
|
1979
|
-
*
|
|
1872
|
+
* Similar to {@link observe}, creates a reactive scope that re-executes the function
|
|
1873
|
+
* when its proxied dependencies change.
|
|
1874
|
+
*
|
|
1875
|
+
* **Difference:** Updates run **synchronously and immediately** after the proxy modification
|
|
1876
|
+
* that triggered the update occurs.
|
|
1877
|
+
*
|
|
1878
|
+
* **Caution:** Use sparingly. Immediate execution bypasses Aberdeen's usual batching and
|
|
1879
|
+
* ordering optimizations, which can lead to performance issues or observing inconsistent
|
|
1880
|
+
* intermediate states if multiple related updates are applied sequentially.
|
|
1881
|
+
* Prefer {@link observe} or {@link $} for most use cases.
|
|
1882
|
+
*
|
|
1883
|
+
* @param func - The function to execute reactively and synchronously.
|
|
1884
|
+
*
|
|
1885
|
+
* @example
|
|
1886
|
+
* ```javascript
|
|
1887
|
+
* const state = proxy({ single: 'A' });
|
|
1888
|
+
*
|
|
1889
|
+
* immediateObserve(() => {
|
|
1890
|
+
* state.double = state.single + state.single
|
|
1891
|
+
* });
|
|
1892
|
+
* console.log(state.double); // 'AA'
|
|
1893
|
+
*
|
|
1894
|
+
* state.single = 'B';
|
|
1895
|
+
* // Synchronously:
|
|
1896
|
+
* console.log(state.double); // 'BB'
|
|
1897
|
+
* ```
|
|
1980
1898
|
*/
|
|
1981
|
-
export function immediateObserve(func: () => void)
|
|
1982
|
-
|
|
1899
|
+
export function immediateObserve(func: () => void) {
|
|
1900
|
+
new ImmediateScope(currentScope.parentElement, func);
|
|
1983
1901
|
}
|
|
1984
1902
|
|
|
1985
|
-
|
|
1986
1903
|
/**
|
|
1987
|
-
*
|
|
1988
|
-
|
|
1989
|
-
* @param func - The function to be (repeatedly) executed, possibly adding DOM elements to `parentElement`.
|
|
1990
|
-
* @param parentElement - A DOM element that will be used as the parent element for calls to `$`.
|
|
1991
|
-
* @returns The mount id (usable for `unmount`) if this is a top-level mount.
|
|
1904
|
+
* Attaches a reactive Aberdeen UI fragment to an existing DOM element. Without the use of
|
|
1905
|
+
* this function, {@link $} will assume `document.body` as its root.
|
|
1992
1906
|
*
|
|
1993
|
-
*
|
|
1994
|
-
*
|
|
1995
|
-
*
|
|
1996
|
-
* setInterval(() => store.modify(v => v+1), 1000)
|
|
1907
|
+
* It creates a top-level reactive scope associated with the `parentElement`. The provided
|
|
1908
|
+
* function `func` is executed immediately within this scope. Any proxied data read by `func`
|
|
1909
|
+
* will cause it to re-execute when the data changes, updating the DOM elements created within it.
|
|
1997
1910
|
*
|
|
1998
|
-
*
|
|
1999
|
-
*
|
|
2000
|
-
* }
|
|
2001
|
-
*
|
|
1911
|
+
* Calls to {@link $} inside `func` will append nodes to `parentElement`.
|
|
1912
|
+
* You can nest {@link observe} or other {@link $} scopes within `func`.
|
|
1913
|
+
* Use {@link unmountAll} to clean up all mounted scopes and their DOM nodes.
|
|
1914
|
+
*
|
|
1915
|
+
* Mounting scopes happens reactively, meaning that if this function is called from within another
|
|
1916
|
+
* ({@link observe} or {@link $} or {@link mount}) scope that gets cleaned up, so will the mount.
|
|
2002
1917
|
*
|
|
2003
|
-
*
|
|
2004
|
-
*
|
|
2005
|
-
* let selected = new Store(0)
|
|
2006
|
-
* let colors = new Store(new Map())
|
|
1918
|
+
* @param parentElement - The native DOM `Element` to which the UI fragment will be appended.
|
|
1919
|
+
* @param func - The function that defines the UI fragment, typically containing calls to {@link $}.
|
|
2007
1920
|
*
|
|
2008
|
-
*
|
|
2009
|
-
*
|
|
2010
|
-
*
|
|
2011
|
-
*
|
|
1921
|
+
* @example Basic Mount
|
|
1922
|
+
* ```javascript
|
|
1923
|
+
* // Create a pre-existing DOM structure (without Aberdeen)
|
|
1924
|
+
* document.body.innerHTML = `<h3>Static content <span id="title-extra"></span></h3><div class="box" id="app-root"></div>`;
|
|
1925
|
+
*
|
|
1926
|
+
* import { mount, $, proxy } from 'aberdeen';
|
|
2012
1927
|
*
|
|
2013
|
-
*
|
|
2014
|
-
*
|
|
2015
|
-
* $('h2', {text: '#' + selected.get()})
|
|
2016
|
-
* $('input', {type: 'color', value: '#ffffff' bind: colors(selected.get())})
|
|
2017
|
-
* })
|
|
1928
|
+
* const runTime = proxy(0);
|
|
1929
|
+
* setInterval(() => runTime.value++, 1000);
|
|
2018
1930
|
*
|
|
2019
|
-
*
|
|
2020
|
-
*
|
|
2021
|
-
*
|
|
2022
|
-
*
|
|
2023
|
-
*
|
|
2024
|
-
*
|
|
1931
|
+
* mount(document.getElementById('app-root'), () => {
|
|
1932
|
+
* $('h4:Aberdeen App');
|
|
1933
|
+
* $(`p:Run time: ${runTime.value}s`);
|
|
1934
|
+
* // Conditionally render some content somewhere else in the static page
|
|
1935
|
+
* if (runTime.value&1) {
|
|
1936
|
+
* mount(document.getElementById('title-extra'), () =>
|
|
1937
|
+
* $(`i:(${runTime.value}s)`)
|
|
1938
|
+
* );
|
|
1939
|
+
* }
|
|
1940
|
+
* });
|
|
2025
1941
|
* ```
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
if (parentElement === scope._parentElement) {
|
|
2030
|
-
throw new Error("Only a single mount per parent element")
|
|
2031
|
-
}
|
|
2032
|
-
}
|
|
2033
|
-
|
|
2034
|
-
return _mount(parentElement, func, SimpleScope)
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
|
-
let maxTopScopeId = 0
|
|
2038
|
-
const topScopes: Map<number, SimpleScope> = new Map()
|
|
2039
|
-
|
|
2040
|
-
function _mount(parentElement: Element | undefined, func: () => void, MountScope: typeof SimpleScope): number | undefined {
|
|
2041
|
-
let scope
|
|
2042
|
-
if (parentElement || !currentScope) {
|
|
2043
|
-
scope = new MountScope(parentElement, undefined, 0, func)
|
|
2044
|
-
} else {
|
|
2045
|
-
scope = new MountScope(currentScope._parentElement, currentScope._lastChild || currentScope._precedingSibling, currentScope._queueOrder+1, func)
|
|
2046
|
-
currentScope._lastChild = scope
|
|
2047
|
-
}
|
|
2048
|
-
|
|
2049
|
-
// Do the initial run
|
|
2050
|
-
scope._update()
|
|
1942
|
+
*
|
|
1943
|
+
* Note how the inner mount behaves reactively as well, automatically unmounting when it's parent observer scope re-runs.
|
|
1944
|
+
*/
|
|
2051
1945
|
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
if (currentScope) {
|
|
2055
|
-
currentScope._cleaners.push(scope)
|
|
2056
|
-
} else {
|
|
2057
|
-
topScopes.set(++maxTopScopeId, scope)
|
|
2058
|
-
return maxTopScopeId
|
|
2059
|
-
}
|
|
1946
|
+
export function mount(parentElement: Element, func: () => void) {
|
|
1947
|
+
new MountScope(parentElement, func);
|
|
2060
1948
|
}
|
|
2061
1949
|
|
|
2062
1950
|
/**
|
|
2063
|
-
*
|
|
2064
|
-
*
|
|
2065
|
-
*
|
|
1951
|
+
* Removes all Aberdeen-managed DOM nodes and stops all active reactive scopes
|
|
1952
|
+
* (created by {@link mount}, {@link observe}, {@link $} with functions, etc.).
|
|
1953
|
+
*
|
|
1954
|
+
* This effectively cleans up the entire Aberdeen application state.
|
|
2066
1955
|
*/
|
|
2067
|
-
export function
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
topScopes.clear()
|
|
2071
|
-
} else {
|
|
2072
|
-
let scope = topScopes.get(id)
|
|
2073
|
-
if (!scope) throw new Error("No such mount "+id)
|
|
2074
|
-
topScopes.delete(id)
|
|
2075
|
-
scope._remove()
|
|
2076
|
-
}
|
|
1956
|
+
export function unmountAll() {
|
|
1957
|
+
ROOT_SCOPE.remove();
|
|
1958
|
+
cssCount = 0;
|
|
2077
1959
|
}
|
|
2078
1960
|
|
|
2079
|
-
|
|
2080
|
-
|
|
1961
|
+
/**
|
|
1962
|
+
* Executes a function *without* creating subscriptions in the current reactive scope, and returns its result.
|
|
2081
1963
|
*
|
|
2082
|
-
*
|
|
2083
|
-
*
|
|
2084
|
-
* @example
|
|
2085
|
-
* ```
|
|
2086
|
-
* import {Store, peek, text} from aberdeen
|
|
1964
|
+
* This is useful when you need to access reactive data inside a reactive scope (like {@link observe})
|
|
1965
|
+
* but do not want changes to that specific data to trigger a re-execute of the scope.
|
|
2087
1966
|
*
|
|
2088
|
-
*
|
|
1967
|
+
* @template T The type of the return value of your function.
|
|
2089
1968
|
*
|
|
2090
|
-
*
|
|
2091
|
-
*
|
|
2092
|
-
*
|
|
2093
|
-
*
|
|
2094
|
-
*
|
|
1969
|
+
* @param func - The function to execute without creating subscriptions.
|
|
1970
|
+
* @returns Whatever `func` returns.
|
|
1971
|
+
*
|
|
1972
|
+
* @example Peeking within observe
|
|
1973
|
+
* ```typescript
|
|
1974
|
+
* const data = proxy({ a: 1, b: 2 });
|
|
1975
|
+
* observe(() => {
|
|
1976
|
+
* // re-executes only when data.a changes, because data.b is peeked.
|
|
1977
|
+
* const b = peek(() => data.b);
|
|
1978
|
+
* console.log(`A is ${data.a}, B was ${b} when A changed.`);
|
|
1979
|
+
* });
|
|
1980
|
+
* data.b = 3; // Does not trigger console.log
|
|
1981
|
+
* data.a = 2; // Triggers console.log (logs "A is 2, B was 3 when A changed.")
|
|
2095
1982
|
* ```
|
|
2096
1983
|
*
|
|
2097
|
-
* In the above example `store.get(0)` could be replaced with `store.peek(0)` to achieve the
|
|
2098
|
-
* same result without `peek()` wrapping everything. There is no non-subscribing equivalent
|
|
2099
|
-
* for `count()` however.
|
|
2100
1984
|
*/
|
|
2101
1985
|
export function peek<T>(func: () => T): T {
|
|
2102
|
-
|
|
2103
|
-
currentScope = undefined
|
|
1986
|
+
peeking++;
|
|
2104
1987
|
try {
|
|
2105
|
-
return func()
|
|
1988
|
+
return func();
|
|
2106
1989
|
} finally {
|
|
2107
|
-
|
|
1990
|
+
peeking--;
|
|
2108
1991
|
}
|
|
2109
|
-
}
|
|
1992
|
+
}
|
|
2110
1993
|
|
|
2111
|
-
|
|
2112
|
-
|
|
1994
|
+
/** When using an object as `source`. */
|
|
1995
|
+
export function map<IN,OUT>(source: Record<string|symbol,IN>, func: (value: IN, index: string|symbol) => undefined|OUT): Record<string|symbol,OUT>;
|
|
1996
|
+
/** When using an array as `source`. */
|
|
1997
|
+
export function map<IN,OUT>(source: Array<IN>, func: (value: IN, index: number) => undefined|OUT): Array<OUT>;
|
|
1998
|
+
/**
|
|
1999
|
+
* Reactively maps/filters items from a proxied source array or object to a new proxied array or object.
|
|
2000
|
+
*
|
|
2001
|
+
* It iterates over the `target` proxy. For each item, it calls `func`.
|
|
2002
|
+
* - If `func` returns a value, it's added to the result proxy under the same key/index.
|
|
2003
|
+
* - If `func` returns `undefined`, the item is skipped (filtered out).
|
|
2004
|
+
*
|
|
2005
|
+
* The returned proxy automatically updates when:
|
|
2006
|
+
* - Items are added/removed/updated in the `target` proxy.
|
|
2007
|
+
* - Any proxied data read *within* the `func` call changes (for a specific item).
|
|
2008
|
+
*
|
|
2009
|
+
* @param func - A function `(value, key) => mappedValue | undefined` that transforms each item.
|
|
2010
|
+
* It receives the item's value and its key/index. Return `undefined` to filter the item out.
|
|
2011
|
+
* @returns A new proxied array or object containing the mapped values.
|
|
2012
|
+
* @template IN The type of items in the source proxy.
|
|
2013
|
+
* @template OUT The type of items in the resulting proxy.
|
|
2014
|
+
*
|
|
2015
|
+
* @example Map array values
|
|
2016
|
+
* ```typescript
|
|
2017
|
+
* const numbers = proxy([1, 2, 3]);
|
|
2018
|
+
* const doubled = map(numbers, (n) => n * 2);
|
|
2019
|
+
* // doubled is proxy([2, 4, 6])
|
|
2020
|
+
*
|
|
2021
|
+
* observe(() => console.log(doubled)); // Logs updates
|
|
2022
|
+
* numbers.push(4); // doubled becomes proxy([2, 4, 6, 8])
|
|
2023
|
+
* ```
|
|
2024
|
+
*
|
|
2025
|
+
* @example Filter and map object properties
|
|
2026
|
+
* ```typescript
|
|
2027
|
+
* const users = proxy({
|
|
2028
|
+
* 'u1': { name: 'Alice', active: true },
|
|
2029
|
+
* 'u2': { name: 'Bob', active: false },
|
|
2030
|
+
* 'u3': { name: 'Charlie', active: true }
|
|
2031
|
+
* });
|
|
2032
|
+
*
|
|
2033
|
+
* const activeUserNames = map(users, (user) => user.active ? user.name : undefined);
|
|
2034
|
+
* // activeUserNames is proxy({ u1: 'Alice', u3: 'Charlie' })
|
|
2035
|
+
* observe(() => console.log(Object.values(activeUserNames)));
|
|
2036
|
+
*
|
|
2037
|
+
* users.u2.active = true;
|
|
2038
|
+
* // activeUserNames becomes proxy({ u1: 'Alice', u2: 'Bob', u3: 'Charlie' })
|
|
2039
|
+
* ```
|
|
2113
2040
|
*/
|
|
2041
|
+
export function map(source: any, func: (value: DatumType, key: any) => any): any {
|
|
2042
|
+
let out = optProxy(source instanceof Array ? [] : {});
|
|
2043
|
+
onEach(source, (item: DatumType, key: symbol|string|number) => {
|
|
2044
|
+
let value = func(item, key);
|
|
2045
|
+
if (value !== undefined) {
|
|
2046
|
+
out[key] = value;
|
|
2047
|
+
clean(() => {
|
|
2048
|
+
delete out[key];
|
|
2049
|
+
})
|
|
2050
|
+
}
|
|
2051
|
+
})
|
|
2052
|
+
return out
|
|
2053
|
+
}
|
|
2114
2054
|
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2055
|
+
/** When using an array as `source`. */
|
|
2056
|
+
export function multiMap<IN,OUT extends {[key: string|symbol]: DatumType}>(source: Array<IN>, func: (value: IN, index: number) => OUT | undefined): OUT;
|
|
2057
|
+
/** When using an object as `source`. */
|
|
2058
|
+
export function multiMap<K extends string|number|symbol,IN,OUT extends {[key: string|symbol]: DatumType}>(source: Record<K,IN>, func: (value: IN, index: K) => OUT | undefined): OUT;
|
|
2059
|
+
/**
|
|
2060
|
+
* Reactively maps items from a source proxy (array or object) to a target proxied object,
|
|
2061
|
+
* where each source item can contribute multiple key-value pairs to the target.
|
|
2062
|
+
*
|
|
2063
|
+
* It iterates over the `target` proxy. For each item, it calls `func`.
|
|
2064
|
+
* - If `func` returns an object, all key-value pairs from that object are added to the result proxy.
|
|
2065
|
+
* - If `func` returns `undefined`, the item contributes nothing.
|
|
2066
|
+
*
|
|
2067
|
+
* The returned proxy automatically updates when:
|
|
2068
|
+
* - Items are added/removed/updated in the `target` proxy.
|
|
2069
|
+
* - Any proxied data read *within* the `func` call changes (for a specific item).
|
|
2070
|
+
* - If multiple input items produce the same output key, the last one processed usually "wins",
|
|
2071
|
+
* but the exact behavior on collision depends on update timing.
|
|
2072
|
+
*
|
|
2073
|
+
* This is useful for "flattening" or "indexing" data, or converting an observable array to an observable object.
|
|
2074
|
+
*
|
|
2075
|
+
* @param source - The source proxied array or object.
|
|
2076
|
+
* @param func - A function `(value, key) => ({...pairs} | undefined)` that transforms an item
|
|
2077
|
+
* into an object of key-value pairs to add, or `undefined` to add nothing.
|
|
2078
|
+
* @returns A new proxied object containing the aggregated key-value pairs.
|
|
2079
|
+
* @template IN The type of items in the source proxy.
|
|
2080
|
+
* @template OUT The type of the aggregated output object (should encompass all possible key-value pairs).
|
|
2081
|
+
*
|
|
2082
|
+
* @example Creating an index from an array
|
|
2083
|
+
* ```typescript
|
|
2084
|
+
* const items = proxy([
|
|
2085
|
+
* { id: 'a', value: 10 },
|
|
2086
|
+
* { id: 'b', value: 20 },
|
|
2087
|
+
* ]);
|
|
2088
|
+
* const itemsById = multiMap(items, (item) => ({
|
|
2089
|
+
* [item.id]: item.value,
|
|
2090
|
+
* [item.id+item.id]: item.value*10,
|
|
2091
|
+
* }));
|
|
2092
|
+
* // itemsById is proxy({ a: 10, aa: 100, b: 20, bb: 200 })
|
|
2093
|
+
*
|
|
2094
|
+
* $(() => console.log(itemsById));
|
|
2095
|
+
*
|
|
2096
|
+
* items.push({ id: 'c', value: 30 });
|
|
2097
|
+
* // itemsById becomes proxy({ a: 10, aa: 100, b: 20, bb: 200, c: 30, cc: 300 })
|
|
2098
|
+
* ```
|
|
2099
|
+
*/
|
|
2100
|
+
export function multiMap(source: any, func: (value: DatumType, key: any) => Record<string|symbol,DatumType>): any {
|
|
2101
|
+
let out = optProxy({});
|
|
2102
|
+
onEach(source, (item: DatumType, key: symbol|string|number) => {
|
|
2103
|
+
let pairs = func(item, key);
|
|
2104
|
+
if (pairs) {
|
|
2105
|
+
for(let key in pairs) out[key] = pairs[key];
|
|
2106
|
+
clean(() => {
|
|
2107
|
+
for(let key in pairs) delete out[key];
|
|
2108
|
+
})
|
|
2136
2109
|
}
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2110
|
+
})
|
|
2111
|
+
return out
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
/** When using an object as `array`. */
|
|
2115
|
+
export function partition<OUT_K extends string|number|symbol, IN_V>(source: IN_V[], func: (value: IN_V, key: number) => undefined | OUT_K | OUT_K[]): Record<OUT_K,Record<number,IN_V>>;
|
|
2116
|
+
/** When using an object as `source`. */
|
|
2117
|
+
export function partition<IN_K extends string|number|symbol, OUT_K extends string|number|symbol, IN_V>(source: Record<IN_K,IN_V>, func: (value: IN_V, key: IN_K) => undefined | OUT_K | OUT_K[]): Record<OUT_K,Record<IN_K,IN_V>>;
|
|
2118
|
+
|
|
2119
|
+
/**
|
|
2120
|
+
* @overload
|
|
2121
|
+
* Reactively partitions items from a source proxy (array or object) into multiple "bucket" proxies
|
|
2122
|
+
* based on keys determined by a classifier function.
|
|
2123
|
+
*
|
|
2124
|
+
* This function iterates through the `source` proxy using {@link onEach}. For each item,
|
|
2125
|
+
* it calls the classifier `func`, which should return:
|
|
2126
|
+
* - A single key (`OUT_K`): The item belongs to the bucket with this key.
|
|
2127
|
+
* - An array of keys (`OUT_K[]`): The item belongs to all buckets specified in the array.
|
|
2128
|
+
* - `undefined`: The item is not placed in any bucket.
|
|
2129
|
+
*
|
|
2130
|
+
* The function returns a main proxied object. The keys of this object are the bucket keys (`OUT_K`)
|
|
2131
|
+
* returned by `func`. Each value associated with a bucket key is another proxied object (the "bucket").
|
|
2132
|
+
* This inner bucket object maps the *original* keys/indices from the `source` to the items
|
|
2133
|
+
* themselves that were classified into that bucket.
|
|
2134
|
+
*
|
|
2135
|
+
* The entire structure is reactive. Changes in the `source` proxy (adding/removing/updating items)
|
|
2136
|
+
* or changes in dependencies read by the `func` will cause the output partitioning to update automatically.
|
|
2137
|
+
* Buckets are created dynamically as needed and removed when they become empty.
|
|
2138
|
+
*
|
|
2139
|
+
* @param source - The input proxied Array or Record (e.g., created by {@link proxy}) containing the items to partition.
|
|
2140
|
+
* @param func - A classifier function `(value: IN_V, key: IN_K | number) => undefined | OUT_K | OUT_K[]`.
|
|
2141
|
+
* It receives the item's value and its original key/index from the `source`. It returns the bucket key(s)
|
|
2142
|
+
* the item belongs to, or `undefined` to ignore the item.
|
|
2143
|
+
* @returns A proxied object where keys are the bucket identifiers (`OUT_K`) and values are proxied Records
|
|
2144
|
+
* (`Record<IN_K | number, IN_V>`) representing the buckets. Each bucket maps original source keys/indices
|
|
2145
|
+
* to the items belonging to that bucket.
|
|
2146
|
+
*
|
|
2147
|
+
* @template OUT_K - The type of the keys used for the output buckets (string, number, or symbol).
|
|
2148
|
+
* @template IN_V - The type of the values in the source proxy.
|
|
2149
|
+
* @template IN_K - The type of the keys in the source proxy (if it's a Record).
|
|
2150
|
+
*
|
|
2151
|
+
* @example Grouping items by a property
|
|
2152
|
+
* ```typescript
|
|
2153
|
+
* interface Product { id: string; category: string; name: string; }
|
|
2154
|
+
* const products = proxy<Product[]>([
|
|
2155
|
+
* { id: 'p1', category: 'Fruit', name: 'Apple' },
|
|
2156
|
+
* { id: 'p2', category: 'Veg', name: 'Carrot' },
|
|
2157
|
+
* { id: 'p3', category: 'Fruit', name: 'Banana' },
|
|
2158
|
+
* ]);
|
|
2159
|
+
*
|
|
2160
|
+
* // Partition products by category. Output keys are categories (string).
|
|
2161
|
+
* // Inner keys are original array indices (number).
|
|
2162
|
+
* const productsByCategory = partition(products, (product) => product.category);
|
|
2163
|
+
*
|
|
2164
|
+
* // Reactively show the data structure
|
|
2165
|
+
* dump(productsByCategory);
|
|
2166
|
+
*
|
|
2167
|
+
* // Make random changes to the categories, to show reactiveness
|
|
2168
|
+
* setInterval(() => products[0|(Math.random()*3)].category = ['Snack','Fruit','Veg'][0|(Math.random()*3)], 2000);
|
|
2169
|
+
* ```
|
|
2170
|
+
*
|
|
2171
|
+
* @example Item in multiple buckets
|
|
2172
|
+
* ```typescript
|
|
2173
|
+
* interface User { id: number; tags: string[]; name: string; }
|
|
2174
|
+
* const users = proxy({
|
|
2175
|
+
* 'u1': { name: 'Alice', tags: ['active', 'new'] },
|
|
2176
|
+
* 'u2': { name: 'Bob', tags: ['active'] }
|
|
2177
|
+
* });
|
|
2178
|
+
*
|
|
2179
|
+
* // Partition users by tag. Output keys are tags (string).
|
|
2180
|
+
* // Inner keys are original object keys (string: 'u1', 'u2').
|
|
2181
|
+
* const usersByTag = partition(users, (user) => user.tags);
|
|
2182
|
+
*
|
|
2183
|
+
* console.log(usersByTag);
|
|
2184
|
+
* ```
|
|
2185
|
+
*/
|
|
2186
|
+
export function partition<IN_K extends string|number|symbol, OUT_K extends string|number|symbol, IN_V>(source: Record<IN_K,IN_V>, func: (value: IN_V, key: IN_K) => undefined | OUT_K | OUT_K[]): Record<OUT_K,Record<IN_K,IN_V>> {
|
|
2187
|
+
const unproxiedOut = {} as Record<OUT_K,Record<IN_K,IN_V>>;
|
|
2188
|
+
const out = proxy(unproxiedOut);
|
|
2189
|
+
onEach(source, (item: IN_V, key: IN_K) => {
|
|
2190
|
+
let rsp = func(item, key);
|
|
2191
|
+
if (rsp != null) {
|
|
2192
|
+
const buckets = rsp instanceof Array ? rsp : [rsp];
|
|
2193
|
+
if (buckets.length) {
|
|
2194
|
+
for(let bucket of buckets) {
|
|
2195
|
+
if (unproxiedOut[bucket]) out[bucket][key] = item;
|
|
2196
|
+
else out[bucket] = {[key]: item} as Record<IN_K, IN_V>;
|
|
2197
|
+
}
|
|
2198
|
+
clean(() => {
|
|
2199
|
+
for(let bucket of buckets) {
|
|
2200
|
+
delete out[bucket][key];
|
|
2201
|
+
if (isObjEmpty(unproxiedOut[bucket])) delete out[bucket];
|
|
2202
|
+
}
|
|
2203
|
+
})
|
|
2204
|
+
}
|
|
2144
2205
|
}
|
|
2145
|
-
|
|
2206
|
+
})
|
|
2207
|
+
return out;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
|
|
2211
|
+
/**
|
|
2212
|
+
* Renders a live, recursive dump of a proxied data structure (or any value)
|
|
2213
|
+
* into the DOM at the current {@link $} insertion point.
|
|
2214
|
+
*
|
|
2215
|
+
* Uses `<ul>` and `<li>` elements to display object properties and array items.
|
|
2216
|
+
* Updates reactively if the dumped data changes. Primarily intended for debugging purposes.
|
|
2217
|
+
*
|
|
2218
|
+
* @param data - The proxied data structure (or any value) to display.
|
|
2219
|
+
* @returns The original `data` argument, allowing for chaining.
|
|
2220
|
+
* @template T - The type of the data being dumped.
|
|
2221
|
+
*
|
|
2222
|
+
* @example Dumping reactive state
|
|
2223
|
+
* ```typescript
|
|
2224
|
+
* import { $, proxy, dump } from 'aberdeen';
|
|
2225
|
+
*
|
|
2226
|
+
* const state = proxy({
|
|
2227
|
+
* user: { name: 'Frank', kids: 1 },
|
|
2228
|
+
* items: ['a', 'b']
|
|
2229
|
+
* });
|
|
2230
|
+
*
|
|
2231
|
+
* $('h2:Live State Dump');
|
|
2232
|
+
* dump(state);
|
|
2233
|
+
*
|
|
2234
|
+
* // Change state later, the dump in the DOM will update
|
|
2235
|
+
* setTimeout(() => { state.user.kids++; state.items.push('c'); }, 2000);
|
|
2236
|
+
* ```
|
|
2237
|
+
*/
|
|
2238
|
+
export function dump<T>(data: T): T {
|
|
2239
|
+
if (data && typeof data === 'object') {
|
|
2240
|
+
$({text: data instanceof Array ? "<array>" : "<object>"});
|
|
2241
|
+
$('ul', () => {
|
|
2242
|
+
onEach(data as any, (value, key) => {
|
|
2243
|
+
$('li:'+JSON.stringify(key)+": ", () => {
|
|
2244
|
+
dump(value)
|
|
2245
|
+
})
|
|
2246
|
+
})
|
|
2247
|
+
})
|
|
2146
2248
|
} else {
|
|
2147
|
-
|
|
2148
|
-
return value
|
|
2249
|
+
$({text: JSON.stringify(data)})
|
|
2149
2250
|
}
|
|
2251
|
+
return data
|
|
2150
2252
|
}
|
|
2151
2253
|
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2254
|
+
/*
|
|
2255
|
+
* Helper functions
|
|
2256
|
+
*/
|
|
2155
2257
|
|
|
2156
2258
|
/* c8 ignore start */
|
|
2157
|
-
function internalError(code: number) {
|
|
2158
|
-
throw new Error("Aberdeen internal error "+code)
|
|
2259
|
+
function internalError(code: number): never {
|
|
2260
|
+
throw new Error("Aberdeen internal error "+code);
|
|
2159
2261
|
}
|
|
2160
2262
|
/* c8 ignore end */
|
|
2161
2263
|
|
|
2162
2264
|
function handleError(e: any, showMessage: boolean) {
|
|
2163
2265
|
try {
|
|
2164
|
-
if (onError(e) === false) showMessage = false
|
|
2165
|
-
} catch {
|
|
2166
|
-
|
|
2167
|
-
}
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2266
|
+
if (onError(e) === false) showMessage = false;
|
|
2267
|
+
} catch (e) {
|
|
2268
|
+
console.error(e);
|
|
2269
|
+
}
|
|
2270
|
+
try {
|
|
2271
|
+
if (showMessage) $('div.aberdeen-error:Error');
|
|
2272
|
+
} catch {
|
|
2273
|
+
// Error while adding the error marker to the DOM. Apparently, we're in
|
|
2274
|
+
// an awkward context. The error should already have been logged by
|
|
2275
|
+
// onError, so let's not confuse things by generating more errors.
|
|
2172
2276
|
}
|
|
2173
2277
|
}
|
|
2174
2278
|
|
|
2175
2279
|
/** @internal */
|
|
2176
|
-
export function withEmitHandler(handler: (
|
|
2177
|
-
const oldEmitHandler =
|
|
2178
|
-
|
|
2280
|
+
export function withEmitHandler(handler: (target: TargetType, index: any, newData: DatumType, oldData: DatumType) => void, func: ()=>void) {
|
|
2281
|
+
const oldEmitHandler = emit;
|
|
2282
|
+
emit = handler;
|
|
2179
2283
|
try {
|
|
2180
|
-
func()
|
|
2284
|
+
func();
|
|
2181
2285
|
} finally {
|
|
2182
|
-
|
|
2286
|
+
emit = oldEmitHandler;
|
|
2183
2287
|
}
|
|
2184
2288
|
}
|
|
2185
2289
|
|