@converse/skeletor 0.0.8 → 1.0.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/README.md +21 -92
- package/dist/skeletor.d.ts +764 -0
- package/dist/skeletor.js +10206 -6249
- package/dist/skeletor.min.js +72 -6
- package/package.json +59 -33
- package/src/collection.ts +846 -0
- package/src/drivers/sessionStorage.ts +297 -0
- package/src/eventemitter.ts +172 -0
- package/src/helpers.ts +179 -0
- package/src/index.ts +84 -0
- package/src/listening.ts +86 -0
- package/src/model.ts +520 -0
- package/src/storage.ts +293 -0
- package/src/types.ts +189 -0
- package/src/utils/events.ts +224 -0
- package/dist/skeletor.js.map +0 -1
- package/dist/skeletor.min.js.map +0 -1
- package/src/collection.js +0 -740
- package/src/drivers/sessionStorage.js +0 -306
- package/src/drivers/sessionStorage.js~ +0 -307
- package/src/element.js +0 -188
- package/src/events.js +0 -328
- package/src/helpers.js +0 -167
- package/src/history.js +0 -301
- package/src/main.js +0 -43
- package/src/model.js +0 -437
- package/src/overview.js +0 -191
- package/src/router.js +0 -119
- package/src/storage.js +0 -258
- package/src/view.js +0 -267
package/src/storage.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IndexedDB, localStorage and sessionStorage adapter
|
|
3
|
+
*/
|
|
4
|
+
import * as memoryDriver from 'localforage-driver-memory';
|
|
5
|
+
import cloneDeep from 'lodash-es/cloneDeep';
|
|
6
|
+
import isString from 'lodash-es/isString';
|
|
7
|
+
import localForage from 'localforage';
|
|
8
|
+
import mergebounce from 'mergebounce';
|
|
9
|
+
import sessionStorageWrapper from './drivers/sessionStorage';
|
|
10
|
+
import { extendPrototype as extendPrototypeWithSetItems } from 'localforage-setitems';
|
|
11
|
+
import { extendPrototype as extendPrototypeWithGetItems } from '@converse/localforage-getitems/dist/localforage-getitems.es6';
|
|
12
|
+
import { guid } from './helpers';
|
|
13
|
+
import type { Model } from './model';
|
|
14
|
+
import type { Collection } from './collection';
|
|
15
|
+
import type { SyncOptions, SyncOperation } from './types';
|
|
16
|
+
|
|
17
|
+
const IN_MEMORY = memoryDriver._driver;
|
|
18
|
+
localForage.defineDriver(memoryDriver);
|
|
19
|
+
extendPrototypeWithSetItems(localForage);
|
|
20
|
+
extendPrototypeWithGetItems(localForage);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @public
|
|
24
|
+
*/
|
|
25
|
+
export interface LocalForageWithExtensions {
|
|
26
|
+
setItem(key: string, value: any): Promise<any>;
|
|
27
|
+
getItem(key: string): Promise<any>;
|
|
28
|
+
removeItem(key: string): Promise<void>;
|
|
29
|
+
clear(): Promise<void>;
|
|
30
|
+
length(): Promise<number>;
|
|
31
|
+
key(keyIndex: number): Promise<string>;
|
|
32
|
+
keys(): Promise<string[]>;
|
|
33
|
+
setItems?(items: Record<string, any>): Promise<void>;
|
|
34
|
+
getItems?(keys: string[]): Promise<Record<string, any>>;
|
|
35
|
+
debouncedSetItems?: {
|
|
36
|
+
(items: Record<string, any>): Promise<void>;
|
|
37
|
+
flush?: () => void;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @public
|
|
43
|
+
*/
|
|
44
|
+
class BrowserStorage {
|
|
45
|
+
storeInitialized: Promise<void>;
|
|
46
|
+
store: LocalForageWithExtensions;
|
|
47
|
+
name: string;
|
|
48
|
+
|
|
49
|
+
static sessionStorageInitialized: Promise<void>;
|
|
50
|
+
static localForage: typeof localForage;
|
|
51
|
+
|
|
52
|
+
constructor(
|
|
53
|
+
id: string,
|
|
54
|
+
type: 'local' | 'session' | 'indexed' | 'in_memory' | LocalForageWithExtensions,
|
|
55
|
+
batchedWrites = false
|
|
56
|
+
) {
|
|
57
|
+
if (type === 'local' && !window.localStorage) {
|
|
58
|
+
throw new Error('Skeletor.storage: Environment does not support localStorage.');
|
|
59
|
+
} else if (type === 'session' && !window.sessionStorage) {
|
|
60
|
+
throw new Error('Skeletor.storage: Environment does not support sessionStorage.');
|
|
61
|
+
}
|
|
62
|
+
if (isString(type)) {
|
|
63
|
+
this.storeInitialized = this.initStore(type as 'local' | 'session' | 'indexed' | 'in_memory', batchedWrites);
|
|
64
|
+
} else {
|
|
65
|
+
this.store = type;
|
|
66
|
+
if (batchedWrites) {
|
|
67
|
+
this.store.debouncedSetItems = mergebounce((items: Record<string, any>) => this.store.setItems!(items), 50, {
|
|
68
|
+
'promise': true,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
this.storeInitialized = Promise.resolve();
|
|
72
|
+
}
|
|
73
|
+
this.name = id;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param type - The storage type: 'local', 'session', 'indexed', or 'in_memory'
|
|
78
|
+
* @param batchedWrites - Whether to enable batched writes
|
|
79
|
+
*/
|
|
80
|
+
async initStore(type: 'local' | 'session' | 'indexed' | 'in_memory', batchedWrites: boolean): Promise<void> {
|
|
81
|
+
if (type === 'session') {
|
|
82
|
+
await localForage.setDriver(sessionStorageWrapper._driver);
|
|
83
|
+
} else if (type === 'local') {
|
|
84
|
+
await localForage.config({ 'driver': localForage.LOCALSTORAGE });
|
|
85
|
+
} else if (type === 'in_memory') {
|
|
86
|
+
await localForage.config({ 'driver': IN_MEMORY });
|
|
87
|
+
} else if (type !== 'indexed') {
|
|
88
|
+
throw new Error('Skeletor.storage: No storage type was specified');
|
|
89
|
+
}
|
|
90
|
+
this.store = localForage as LocalForageWithExtensions;
|
|
91
|
+
if (batchedWrites) {
|
|
92
|
+
this.store.debouncedSetItems = mergebounce((items: Record<string, any>) => this.store.setItems!(items), 50, {
|
|
93
|
+
'promise': true,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
flush(): void {
|
|
99
|
+
if (this.store.debouncedSetItems && typeof this.store.debouncedSetItems.flush === 'function') {
|
|
100
|
+
this.store.debouncedSetItems.flush();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async clear(): Promise<void> {
|
|
105
|
+
await this.store.removeItem(this.name).catch((e) => console.error(e));
|
|
106
|
+
const re = new RegExp(`^${this.name}-`);
|
|
107
|
+
const keys = await this.store.keys();
|
|
108
|
+
const removed_keys = keys.filter((k) => re.test(k));
|
|
109
|
+
await Promise.all(removed_keys.map((k) => this.store.removeItem(k).catch((e) => console.error(e))));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
sync() {
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
114
|
+
const that = this;
|
|
115
|
+
|
|
116
|
+
async function localSync(method: SyncOperation, model: Model, options: SyncOptions) {
|
|
117
|
+
let resp: any, errorMessage: string | undefined, promise: Promise<any>, new_attributes: any;
|
|
118
|
+
|
|
119
|
+
// We get the collection (and if necessary the model attribute.
|
|
120
|
+
// Waiting for storeInitialized will cause another iteration of
|
|
121
|
+
// the event loop, after which the collection reference will
|
|
122
|
+
// be removed from the model.
|
|
123
|
+
const collection = model.collection;
|
|
124
|
+
if (['patch', 'update'].includes(method)) {
|
|
125
|
+
new_attributes = cloneDeep(model.attributes);
|
|
126
|
+
}
|
|
127
|
+
await that.storeInitialized;
|
|
128
|
+
try {
|
|
129
|
+
const original_attributes = model.attributes;
|
|
130
|
+
switch (method) {
|
|
131
|
+
case 'read':
|
|
132
|
+
if (model.id !== undefined) {
|
|
133
|
+
resp = await that.find(model);
|
|
134
|
+
} else {
|
|
135
|
+
resp = await that.findAll();
|
|
136
|
+
}
|
|
137
|
+
break;
|
|
138
|
+
case 'create':
|
|
139
|
+
resp = await that.create(model, options);
|
|
140
|
+
break;
|
|
141
|
+
case 'patch':
|
|
142
|
+
case 'update':
|
|
143
|
+
if (options.wait) {
|
|
144
|
+
// When `wait` is set to true, Skeletor waits until
|
|
145
|
+
// confirmation of storage before setting the values on
|
|
146
|
+
// the model.
|
|
147
|
+
// However, the new attributes needs to be sent, so it
|
|
148
|
+
// sets them manually on the model and then removes
|
|
149
|
+
// them after calling `sync`.
|
|
150
|
+
// Because our `sync` method is asynchronous and we
|
|
151
|
+
// wait for `storeInitialized`, the attributes are
|
|
152
|
+
// already restored once we get here, so we need to do
|
|
153
|
+
// the attributes dance again.
|
|
154
|
+
model.attributes = new_attributes;
|
|
155
|
+
}
|
|
156
|
+
promise = that.update(model);
|
|
157
|
+
if (options.wait) {
|
|
158
|
+
model.attributes = original_attributes;
|
|
159
|
+
}
|
|
160
|
+
resp = await promise;
|
|
161
|
+
break;
|
|
162
|
+
case 'delete':
|
|
163
|
+
resp = await that.destroy(model, collection);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
} catch (error: any) {
|
|
167
|
+
const storageSize = await that.getStorageSize();
|
|
168
|
+
if (error.code === 22 && storageSize === 0) {
|
|
169
|
+
errorMessage = 'Private browsing is unsupported';
|
|
170
|
+
} else {
|
|
171
|
+
errorMessage = error.message;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (resp) {
|
|
176
|
+
if (options && options.success) {
|
|
177
|
+
// When storing, we don't pass back the response (which is
|
|
178
|
+
// the set attributes returned from localforage because
|
|
179
|
+
// Skeletor sets them again on the model and due to the async
|
|
180
|
+
// nature of localforage it can cause stale attributes to be
|
|
181
|
+
// set on a model after it's been updated in the meantime.
|
|
182
|
+
const data = method === 'read' ? resp : null;
|
|
183
|
+
options.success(data, options);
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
errorMessage = errorMessage ? errorMessage : 'Record Not Found';
|
|
187
|
+
if (options && options.error) {
|
|
188
|
+
options.error(errorMessage);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
localSync.__name__ = 'localSync';
|
|
193
|
+
return localSync;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
removeCollectionReference(model: Model, collection: Collection | undefined): Promise<any> | undefined {
|
|
197
|
+
if (!collection) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const ids = collection.filter((m) => m.id !== model.id).map((m) => this.getItemName(m.id!));
|
|
201
|
+
|
|
202
|
+
return this.store.setItem(this.name, ids);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
addCollectionReference(model: Model, collection: Collection | undefined): Promise<any> | undefined {
|
|
206
|
+
if (!collection) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const ids = collection.map((m) => this.getItemName(m.id!));
|
|
210
|
+
const new_id = this.getItemName(model.id!);
|
|
211
|
+
if (!ids.includes(new_id)) {
|
|
212
|
+
ids.push(new_id);
|
|
213
|
+
}
|
|
214
|
+
return this.store.setItem(this.name, ids);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
getCollectionReferenceData(model: Model): Record<string, string[]> {
|
|
218
|
+
if (!model.collection) {
|
|
219
|
+
return {};
|
|
220
|
+
}
|
|
221
|
+
const ids = model.collection.map((m) => this.getItemName(m.id!));
|
|
222
|
+
const new_id = this.getItemName(model.id!);
|
|
223
|
+
if (!ids.includes(new_id)) {
|
|
224
|
+
ids.push(new_id);
|
|
225
|
+
}
|
|
226
|
+
const result: Record<string, string[]> = {};
|
|
227
|
+
result[this.name] = ids;
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async save(model: Model): Promise<any> {
|
|
232
|
+
if (this.store.setItems) {
|
|
233
|
+
const items: Record<string, any> = {};
|
|
234
|
+
items[this.getItemName(model.id!)] = model.toJSON();
|
|
235
|
+
Object.assign(items, this.getCollectionReferenceData(model));
|
|
236
|
+
return this.store.debouncedSetItems ? this.store.debouncedSetItems(items) : this.store.setItems!(items);
|
|
237
|
+
} else {
|
|
238
|
+
const key = this.getItemName(model.id!);
|
|
239
|
+
const data = await this.store.setItem(key, model.toJSON());
|
|
240
|
+
await this.addCollectionReference(model, model.collection);
|
|
241
|
+
return data;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
create(model: Model, options: SyncOptions): Promise<any> {
|
|
246
|
+
/* Add a model, giving it a (hopefully)-unique GUID, if it doesn't already
|
|
247
|
+
* have an id of it's own.
|
|
248
|
+
*/
|
|
249
|
+
if (!model.id) {
|
|
250
|
+
model.id = guid();
|
|
251
|
+
model.set(model.idAttribute, model.id, options);
|
|
252
|
+
}
|
|
253
|
+
return this.save(model);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
update(model: Model): Promise<any> {
|
|
257
|
+
return this.save(model);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
find(model: Model): Promise<any> {
|
|
261
|
+
return this.store.getItem(this.getItemName(model.id!));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async findAll(): Promise<any[]> {
|
|
265
|
+
/* Return the array of all models currently in storage.
|
|
266
|
+
*/
|
|
267
|
+
const keys = (await this.store.getItem(this.name)) as string[] | null;
|
|
268
|
+
if (keys?.length) {
|
|
269
|
+
const items = await this.store.getItems!(keys);
|
|
270
|
+
return Object.values(items);
|
|
271
|
+
}
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async destroy(model: Model, collection: Collection | undefined): Promise<Model> {
|
|
276
|
+
await this.flush();
|
|
277
|
+
await this.store.removeItem(this.getItemName(model.id!));
|
|
278
|
+
await this.removeCollectionReference(model, collection);
|
|
279
|
+
return model;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async getStorageSize(): Promise<number> {
|
|
283
|
+
return await this.store.length();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
getItemName(id: string | number): string {
|
|
287
|
+
return this.name + '-' + id;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
BrowserStorage.sessionStorageInitialized = localForage.defineDriver(sessionStorageWrapper);
|
|
292
|
+
BrowserStorage.localForage = localForage;
|
|
293
|
+
export default BrowserStorage;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {Collection} from './collection';
|
|
2
|
+
import {Model} from './model';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @public
|
|
6
|
+
*/
|
|
7
|
+
export type SyncOperation = 'create' | 'update' | 'patch' | 'delete' | 'read';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
export type ObjectListenedTo = object & {_listenId?: string};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
export interface SyncOptions {
|
|
18
|
+
url?: string;
|
|
19
|
+
data?: any;
|
|
20
|
+
attrs?: any;
|
|
21
|
+
success?: (data?: any, options?: SyncOptions) => void;
|
|
22
|
+
error?: (error: any) => void;
|
|
23
|
+
xhr?: any;
|
|
24
|
+
wait?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @public
|
|
29
|
+
*/
|
|
30
|
+
export type ClassConstructor<T = any> = new (...args: any[]) => T;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @public
|
|
34
|
+
*/
|
|
35
|
+
export type EventContext = unknown;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @public
|
|
39
|
+
*/
|
|
40
|
+
export type EventCallback = (...args: any[]) => void;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @public
|
|
44
|
+
*/
|
|
45
|
+
export type EventCallbackMap = Record<string, EventCallback>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @public
|
|
49
|
+
*/
|
|
50
|
+
export type EventCallbacksMap = Record<string, EventCallback[]>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @public
|
|
54
|
+
*/
|
|
55
|
+
export type EventListenerMap = Record<string, ListeningType>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @public
|
|
59
|
+
*/
|
|
60
|
+
export interface EventHandlersMap {
|
|
61
|
+
all?: EventHandler[];
|
|
62
|
+
[name: string]: EventHandler[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @public
|
|
67
|
+
*/
|
|
68
|
+
export type ListeningMap = Record<string, ListeningType>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @public
|
|
72
|
+
*/
|
|
73
|
+
export type ObjectWithId = Record<string, any> & {id: string | number};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @public
|
|
77
|
+
*/
|
|
78
|
+
export interface IEventEmitter {
|
|
79
|
+
_events?: Record<string, any>;
|
|
80
|
+
_listeners?: ListeningMap;
|
|
81
|
+
_listeningTo?: ListeningMap;
|
|
82
|
+
_listenId?: string;
|
|
83
|
+
|
|
84
|
+
on(name: string | EventCallbackMap, callback?: EventCallback | EventContext, context?: EventContext): this;
|
|
85
|
+
off(
|
|
86
|
+
name?: string | EventCallbackMap | null,
|
|
87
|
+
callback?: EventCallback | EventContext | null,
|
|
88
|
+
context?: EventContext
|
|
89
|
+
): this;
|
|
90
|
+
trigger(name: string, ...args: any[]): this;
|
|
91
|
+
stopListening(obj?: any, name?: string | EventCallbackMap, callback?: EventCallback): this;
|
|
92
|
+
once(name: string | EventCallbackMap, callback?: EventCallback | EventContext, context?: EventContext): this;
|
|
93
|
+
listenTo(obj: ObjectListenedTo, name: string | EventCallbackMap, callback?: EventCallback): this;
|
|
94
|
+
listenToOnce(obj: any, name: string | EventCallbackMap, callback?: EventCallback): this;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @public
|
|
99
|
+
*/
|
|
100
|
+
export interface EventHandler {
|
|
101
|
+
callback: EventCallback;
|
|
102
|
+
context: any;
|
|
103
|
+
ctx: any;
|
|
104
|
+
listening?: ListeningType | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @public
|
|
109
|
+
*/
|
|
110
|
+
export interface ListeningType {
|
|
111
|
+
_events?: EventCallbackMap;
|
|
112
|
+
cleanup(): void;
|
|
113
|
+
count: number;
|
|
114
|
+
id: string;
|
|
115
|
+
interop: boolean;
|
|
116
|
+
listener: IEventEmitter;
|
|
117
|
+
obj: any;
|
|
118
|
+
|
|
119
|
+
start(name: string | EventCallbackMap, callback: EventCallback, context: any, _listening: ListeningType): this;
|
|
120
|
+
stop(name: string | EventCallbackMap, callback: EventCallback): void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @public
|
|
125
|
+
*/
|
|
126
|
+
export interface EventsApiOptions {
|
|
127
|
+
context?: any;
|
|
128
|
+
ctx?: any;
|
|
129
|
+
listening?: ListeningType;
|
|
130
|
+
listeners?: ListeningMap;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @public
|
|
135
|
+
*/
|
|
136
|
+
export interface OffApiOptions {
|
|
137
|
+
context?: any;
|
|
138
|
+
listeners?: {[key: string]: ListeningType};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @public
|
|
143
|
+
*/
|
|
144
|
+
export type Comparator<T extends Model = Model> = string | boolean | ((a: T, b: T) => number) | ((a?: T) => string);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @public
|
|
148
|
+
*/
|
|
149
|
+
export type OfferFunction = (name: string, callback: EventCallback) => void;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @public
|
|
153
|
+
*/
|
|
154
|
+
export type IterateeFunction = (
|
|
155
|
+
eventsOrMap: EventCallbackMap | EventHandlersMap,
|
|
156
|
+
name: string | null,
|
|
157
|
+
callback: EventCallback | EventHandler | null,
|
|
158
|
+
options: EventsApiOptions | OffApiOptions | OfferFunction | any[]
|
|
159
|
+
) => EventCallbackMap | EventHandlersMap | void;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @public
|
|
163
|
+
*/
|
|
164
|
+
export type ModelAttributes = Record<string | number, any> & {id?: string | number};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @public
|
|
168
|
+
*/
|
|
169
|
+
export type Options = Record<string, any>;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @public
|
|
173
|
+
*/
|
|
174
|
+
export type CollectionOptions<T extends Model = Model> = Options & {
|
|
175
|
+
model?: new (attributes?: Partial<ModelAttributes>, options?: ModelOptions) => T;
|
|
176
|
+
comparator?: Comparator<T>;
|
|
177
|
+
previousModels?: Model[];
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @public
|
|
182
|
+
*/
|
|
183
|
+
export type ModelOptions = Options & {
|
|
184
|
+
collection?: Collection;
|
|
185
|
+
parse?: boolean;
|
|
186
|
+
unset?: boolean;
|
|
187
|
+
silent?: boolean;
|
|
188
|
+
validate?: boolean;
|
|
189
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import once from 'lodash-es/once';
|
|
2
|
+
import type {
|
|
3
|
+
EventCallback,
|
|
4
|
+
EventHandler,
|
|
5
|
+
EventCallbackMap,
|
|
6
|
+
EventsApiOptions,
|
|
7
|
+
IterateeFunction,
|
|
8
|
+
OffApiOptions,
|
|
9
|
+
OfferFunction,
|
|
10
|
+
EventHandlersMap,
|
|
11
|
+
EventContext,
|
|
12
|
+
} from '../types';
|
|
13
|
+
|
|
14
|
+
// Regular expression used to split event strings.
|
|
15
|
+
const eventSplitter = /\s+/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Iterates over the standard `event, callback` (as well as the fancy multiple
|
|
19
|
+
* space-separated events `"change blur", callback` and jQuery-style event
|
|
20
|
+
* maps `{event: callback}`).
|
|
21
|
+
*/
|
|
22
|
+
export function eventsApi(
|
|
23
|
+
iteratee: IterateeFunction,
|
|
24
|
+
events: EventHandlersMap | EventCallbackMap,
|
|
25
|
+
name: string | EventCallbackMap | null,
|
|
26
|
+
callback: EventCallback | EventHandler | EventContext | null,
|
|
27
|
+
opts: EventsApiOptions | OffApiOptions | any[]
|
|
28
|
+
): EventHandlersMap | EventCallbackMap {
|
|
29
|
+
let i = 0;
|
|
30
|
+
let names: string[];
|
|
31
|
+
|
|
32
|
+
if (name && typeof name === 'object') {
|
|
33
|
+
// Handle event maps.
|
|
34
|
+
if (callback !== undefined && 'context' in opts && opts.context === undefined) {
|
|
35
|
+
opts.context = callback;
|
|
36
|
+
}
|
|
37
|
+
for (names = Object.keys(name); i < names.length; i++) {
|
|
38
|
+
events = eventsApi(iteratee, events, names[i], name[names[i]], opts);
|
|
39
|
+
}
|
|
40
|
+
} else if (name && typeof name === 'string' && eventSplitter.test(name)) {
|
|
41
|
+
// Handle space-separated event names by delegating them individually.
|
|
42
|
+
for (names = name.split(eventSplitter); i < names.length; i++) {
|
|
43
|
+
const result = iteratee(events, names[i], callback as EventCallback | EventHandler, opts);
|
|
44
|
+
if (result !== undefined) {
|
|
45
|
+
events = result as EventHandlersMap;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
// Finally, standard events.
|
|
50
|
+
events = iteratee(events, name as string | null, callback as EventCallback | EventHandler, opts) as
|
|
51
|
+
| EventHandlersMap
|
|
52
|
+
| EventCallbackMap;
|
|
53
|
+
}
|
|
54
|
+
return events;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A reducer that adds a callback to the `events` object.
|
|
59
|
+
*/
|
|
60
|
+
export function onApi(
|
|
61
|
+
events: EventHandlersMap,
|
|
62
|
+
name: string,
|
|
63
|
+
callback: EventCallback | null,
|
|
64
|
+
options: EventsApiOptions
|
|
65
|
+
): EventHandlersMap {
|
|
66
|
+
if (callback) {
|
|
67
|
+
const handlers = events[name] || (events[name] = []);
|
|
68
|
+
const context = options.context;
|
|
69
|
+
const ctx = options.ctx;
|
|
70
|
+
const listening = options.listening;
|
|
71
|
+
if (listening) listening.count++;
|
|
72
|
+
|
|
73
|
+
handlers.push({ callback, context, ctx: context || ctx, listening });
|
|
74
|
+
}
|
|
75
|
+
return events;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* An try-catch guarded #on function, to prevent poisoning the global
|
|
80
|
+
* `_listening` variable.
|
|
81
|
+
*/
|
|
82
|
+
export function tryCatchOn(
|
|
83
|
+
obj: any,
|
|
84
|
+
name: string | EventCallbackMap,
|
|
85
|
+
callback: EventCallback | EventContext,
|
|
86
|
+
context: EventContext
|
|
87
|
+
): any {
|
|
88
|
+
try {
|
|
89
|
+
obj.on(name, callback, context);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return e;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The reducing API that removes a callback from the `events` object.
|
|
97
|
+
*/
|
|
98
|
+
export function offApi(
|
|
99
|
+
events: EventHandlersMap,
|
|
100
|
+
name: string | null,
|
|
101
|
+
callback: EventCallback | null,
|
|
102
|
+
options: OffApiOptions
|
|
103
|
+
): EventHandlersMap | void {
|
|
104
|
+
if (!events) return;
|
|
105
|
+
|
|
106
|
+
const context = options.context;
|
|
107
|
+
const listeners = options.listeners;
|
|
108
|
+
let i = 0;
|
|
109
|
+
let names: string[];
|
|
110
|
+
|
|
111
|
+
// Delete all event listeners and "drop" events.
|
|
112
|
+
if (!name && !context && !callback) {
|
|
113
|
+
if (listeners) {
|
|
114
|
+
for (names = Object.keys(listeners); i < names.length; i++) {
|
|
115
|
+
listeners[names[i]].cleanup();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
names = name ? [name] : Object.keys(events);
|
|
122
|
+
for (; i < names.length; i++) {
|
|
123
|
+
const currentName = names[i];
|
|
124
|
+
const handlers = events[currentName];
|
|
125
|
+
|
|
126
|
+
// Bail out if there are no events stored.
|
|
127
|
+
if (!handlers) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Find any remaining events.
|
|
132
|
+
const remaining: EventHandler[] = [];
|
|
133
|
+
for (let j = 0; j < handlers.length; j++) {
|
|
134
|
+
const handler = handlers[j];
|
|
135
|
+
if (
|
|
136
|
+
(callback && callback !== handler.callback && callback !== (handler.callback as any)._callback) ||
|
|
137
|
+
(context && context !== handler.context)
|
|
138
|
+
) {
|
|
139
|
+
remaining.push(handler);
|
|
140
|
+
} else {
|
|
141
|
+
const listening = handler.listening;
|
|
142
|
+
if (listening) listening.stop(currentName, callback!);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Replace events if there are any remaining. Otherwise, clean up.
|
|
147
|
+
if (remaining.length) {
|
|
148
|
+
events[currentName] = remaining;
|
|
149
|
+
} else {
|
|
150
|
+
delete events[currentName];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return events;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Reduces the event callbacks into a map of `{event: onceWrapper}`.
|
|
159
|
+
* `offer` unbinds the `onceWrapper` after it has been called.
|
|
160
|
+
*/
|
|
161
|
+
export function onceMap(
|
|
162
|
+
map: EventCallbackMap,
|
|
163
|
+
name: string,
|
|
164
|
+
callback: EventCallback | null,
|
|
165
|
+
offer: OfferFunction
|
|
166
|
+
): { [name: string]: EventCallback } {
|
|
167
|
+
if (callback) {
|
|
168
|
+
const _once = once(function (this: any, ...args: any[]) {
|
|
169
|
+
offer(name, _once);
|
|
170
|
+
callback.apply(this, args);
|
|
171
|
+
}) as EventCallback & { _callback?: EventCallback };
|
|
172
|
+
map[name] = _once;
|
|
173
|
+
_once._callback = callback;
|
|
174
|
+
}
|
|
175
|
+
return map;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Handles triggering the appropriate event callbacks. */
|
|
179
|
+
export function triggerApi(
|
|
180
|
+
objEvents: EventHandlersMap | null,
|
|
181
|
+
name: string,
|
|
182
|
+
_callback: EventCallback | null,
|
|
183
|
+
args: any[]
|
|
184
|
+
): EventHandlersMap | null {
|
|
185
|
+
if (objEvents) {
|
|
186
|
+
const events = objEvents[name];
|
|
187
|
+
let allEvents = objEvents.all;
|
|
188
|
+
if (events && allEvents) allEvents = allEvents.slice();
|
|
189
|
+
if (events) triggerEvents(events, args);
|
|
190
|
+
if (allEvents) triggerEvents(allEvents, [name].concat(args));
|
|
191
|
+
}
|
|
192
|
+
return objEvents;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A difficult-to-believe, but optimized internal dispatch function for
|
|
197
|
+
* triggering events. Tries to keep the usual cases speedy (most internal
|
|
198
|
+
* Backbone events have 3 arguments).
|
|
199
|
+
*/
|
|
200
|
+
function triggerEvents(events: EventHandler[], args: any[]): void {
|
|
201
|
+
let ev: EventHandler;
|
|
202
|
+
let i = -1;
|
|
203
|
+
const l = events.length;
|
|
204
|
+
const a1 = args[0];
|
|
205
|
+
const a2 = args[1];
|
|
206
|
+
const a3 = args[2];
|
|
207
|
+
switch (args.length) {
|
|
208
|
+
case 0:
|
|
209
|
+
while (++i < l) (ev = events[i]).callback.call(ev.ctx);
|
|
210
|
+
return;
|
|
211
|
+
case 1:
|
|
212
|
+
while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1);
|
|
213
|
+
return;
|
|
214
|
+
case 2:
|
|
215
|
+
while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2);
|
|
216
|
+
return;
|
|
217
|
+
case 3:
|
|
218
|
+
while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2, a3);
|
|
219
|
+
return;
|
|
220
|
+
default:
|
|
221
|
+
while (++i < l) (ev = events[i]).callback.apply(ev.ctx, args);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|