@textbus/collaborate 2.0.0-beta.9 → 2.0.2
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/bundles/collaborate-cursor.d.ts +11 -11
- package/bundles/collaborate-cursor.js +120 -117
- package/bundles/collaborate.d.ts +15 -6
- package/bundles/collaborate.js +257 -219
- package/package.json +7 -7
- package/src/collaborate-cursor.ts +100 -56
- package/src/collaborate.ts +250 -69
- package/tsconfig-build.json +1 -1
package/src/collaborate.ts
CHANGED
@@ -1,10 +1,11 @@
|
|
1
|
-
import { Injectable } from '@tanbo/di'
|
2
|
-
import { Observable, Subject, Subscription } from '@tanbo/stream'
|
1
|
+
import { Inject, Injectable, Optional } from '@tanbo/di'
|
2
|
+
import { delay, filter, map, Observable, Subject, Subscription } from '@tanbo/stream'
|
3
3
|
import {
|
4
|
+
ChangeOrigin, ComponentInitData,
|
4
5
|
ComponentInstance,
|
5
|
-
ContentType,
|
6
|
+
ContentType, Controller,
|
6
7
|
Formats,
|
7
|
-
History,
|
8
|
+
History, HISTORY_STACK_SIZE,
|
8
9
|
makeError,
|
9
10
|
Registry,
|
10
11
|
RootComponentRef,
|
@@ -15,28 +16,89 @@ import {
|
|
15
16
|
Starter,
|
16
17
|
Translator
|
17
18
|
} from '@textbus/core'
|
18
|
-
import {
|
19
|
+
import {
|
20
|
+
Array as YArray,
|
21
|
+
Doc as YDoc,
|
22
|
+
Map as YMap,
|
23
|
+
RelativePosition,
|
24
|
+
Text as YText,
|
25
|
+
Transaction,
|
26
|
+
UndoManager,
|
27
|
+
createAbsolutePositionFromRelativePosition,
|
28
|
+
createRelativePositionFromTypeIndex
|
29
|
+
} from 'yjs'
|
19
30
|
|
20
31
|
import { CollaborateCursor, RemoteSelection } from './collaborate-cursor'
|
21
32
|
import { createUnknownComponent } from './unknown.component'
|
22
33
|
|
23
34
|
const collaborateErrorFn = makeError('Collaborate')
|
24
35
|
|
36
|
+
interface CursorPosition {
|
37
|
+
anchor: RelativePosition
|
38
|
+
focus: RelativePosition
|
39
|
+
}
|
40
|
+
|
41
|
+
class ContentMap {
|
42
|
+
private slotAndYTextMap = new WeakMap<Slot, YText>()
|
43
|
+
private yTextAndSLotMap = new WeakMap<YText, Slot>()
|
44
|
+
|
45
|
+
set(key: Slot, value: YText): void
|
46
|
+
set(key: YText, value: Slot): void
|
47
|
+
set(key: any, value: any) {
|
48
|
+
if (key instanceof Slot) {
|
49
|
+
this.slotAndYTextMap.set(key, value)
|
50
|
+
this.yTextAndSLotMap.set(value, key)
|
51
|
+
} else {
|
52
|
+
this.slotAndYTextMap.set(value, key)
|
53
|
+
this.yTextAndSLotMap.set(key, value)
|
54
|
+
}
|
55
|
+
}
|
56
|
+
|
57
|
+
get(key: Slot): YText | null
|
58
|
+
get(key: YText): Slot | null
|
59
|
+
get(key: any) {
|
60
|
+
if (key instanceof Slot) {
|
61
|
+
return this.slotAndYTextMap.get(key) || null
|
62
|
+
}
|
63
|
+
return this.yTextAndSLotMap.get(key) || null
|
64
|
+
}
|
65
|
+
|
66
|
+
delete(key: Slot | YText) {
|
67
|
+
if (key instanceof Slot) {
|
68
|
+
const v = this.slotAndYTextMap.get(key)
|
69
|
+
this.slotAndYTextMap.delete(key)
|
70
|
+
if (v) {
|
71
|
+
this.yTextAndSLotMap.delete(v)
|
72
|
+
}
|
73
|
+
} else {
|
74
|
+
const v = this.yTextAndSLotMap.get(key)
|
75
|
+
this.yTextAndSLotMap.delete(key)
|
76
|
+
if (v) {
|
77
|
+
this.slotAndYTextMap.delete(v)
|
78
|
+
}
|
79
|
+
}
|
80
|
+
}
|
81
|
+
}
|
82
|
+
|
83
|
+
export abstract class TranslatorFallback {
|
84
|
+
abstract createComponentByData(name: string, data: ComponentInitData): ComponentInstance | null
|
85
|
+
}
|
86
|
+
|
25
87
|
@Injectable()
|
26
88
|
export class Collaborate implements History {
|
27
89
|
onSelectionChange: Observable<SelectionPaths>
|
28
90
|
yDoc = new YDoc()
|
29
91
|
onBack: Observable<void>
|
30
92
|
onForward: Observable<void>
|
31
|
-
onChange: Observable<
|
93
|
+
onChange: Observable<void>
|
32
94
|
onPush: Observable<void>
|
33
95
|
|
34
96
|
get canBack() {
|
35
|
-
return this.manager?.canUndo()
|
97
|
+
return this.manager?.canUndo() || false
|
36
98
|
}
|
37
99
|
|
38
100
|
get canForward() {
|
39
|
-
return this.manager?.canRedo()
|
101
|
+
return this.manager?.canRedo() || false
|
40
102
|
}
|
41
103
|
|
42
104
|
private backEvent = new Subject<void>()
|
@@ -44,7 +106,7 @@ export class Collaborate implements History {
|
|
44
106
|
private changeEvent = new Subject<void>()
|
45
107
|
private pushEvent = new Subject<void>()
|
46
108
|
|
47
|
-
private manager
|
109
|
+
private manager: UndoManager | null = null
|
48
110
|
|
49
111
|
private subscriptions: Subscription[] = []
|
50
112
|
private updateFromRemote = false
|
@@ -55,84 +117,180 @@ export class Collaborate implements History {
|
|
55
117
|
private componentStateSyncCaches = new WeakMap<ComponentInstance, () => void>()
|
56
118
|
|
57
119
|
private selectionChangeEvent = new Subject<SelectionPaths>()
|
120
|
+
private contentMap = new ContentMap()
|
58
121
|
|
59
122
|
private updateRemoteActions: Array<() => void> = []
|
60
123
|
|
61
|
-
constructor(private
|
124
|
+
constructor(@Inject(HISTORY_STACK_SIZE) private stackSize: number,
|
125
|
+
private rootComponentRef: RootComponentRef,
|
62
126
|
private collaborateCursor: CollaborateCursor,
|
127
|
+
private controller: Controller,
|
63
128
|
private scheduler: Scheduler,
|
64
129
|
private translator: Translator,
|
65
130
|
private registry: Registry,
|
66
131
|
private selection: Selection,
|
67
|
-
private starter: Starter
|
68
|
-
|
132
|
+
private starter: Starter,
|
133
|
+
@Optional() private translatorFallback: TranslatorFallback) {
|
134
|
+
this.onSelectionChange = this.selectionChangeEvent.asObservable().pipe(delay())
|
69
135
|
this.onBack = this.backEvent.asObservable()
|
70
136
|
this.onForward = this.forwardEvent.asObservable()
|
71
137
|
this.onChange = this.changeEvent.asObservable()
|
72
138
|
this.onPush = this.pushEvent.asObservable()
|
73
139
|
}
|
74
140
|
|
75
|
-
|
141
|
+
listen() {
|
142
|
+
const root = this.yDoc.getMap('RootComponent')
|
143
|
+
const rootComponent = this.rootComponentRef.component!
|
144
|
+
this.manager = new UndoManager(root, {
|
145
|
+
trackedOrigins: new Set<any>([this.yDoc])
|
146
|
+
})
|
147
|
+
const cursorKey = 'cursor-position'
|
148
|
+
this.manager.on('stack-item-added', event => {
|
149
|
+
event.stackItem.meta.set(cursorKey, this.getRelativeCursorLocation())
|
150
|
+
if (this.manager!.undoStack.length > this.stackSize) {
|
151
|
+
this.manager!.undoStack.shift()
|
152
|
+
}
|
153
|
+
if (event.origin === this.yDoc) {
|
154
|
+
this.pushEvent.next()
|
155
|
+
}
|
156
|
+
this.changeEvent.next()
|
157
|
+
})
|
158
|
+
this.manager.on('stack-item-popped', event => {
|
159
|
+
const position = event.stackItem.meta.get(cursorKey) as CursorPosition
|
160
|
+
if (position) {
|
161
|
+
this.restoreCursorLocation(position)
|
162
|
+
}
|
163
|
+
})
|
76
164
|
this.subscriptions.push(
|
77
|
-
this.starter.onReady.subscribe(() => {
|
78
|
-
this.listen2()
|
79
|
-
}),
|
80
165
|
this.selection.onChange.subscribe(() => {
|
81
166
|
const paths = this.selection.getPaths()
|
82
167
|
this.selectionChangeEvent.next(paths)
|
168
|
+
}),
|
169
|
+
this.scheduler.onDocChanged.pipe(
|
170
|
+
map(item => {
|
171
|
+
return item.filter(i => {
|
172
|
+
return i.from !== ChangeOrigin.Remote
|
173
|
+
})
|
174
|
+
}),
|
175
|
+
filter(item => {
|
176
|
+
return item.length
|
177
|
+
})
|
178
|
+
).subscribe(() => {
|
179
|
+
this.yDoc.transact(() => {
|
180
|
+
this.updateRemoteActions.forEach(fn => {
|
181
|
+
fn()
|
182
|
+
})
|
183
|
+
this.updateRemoteActions = []
|
184
|
+
}, this.yDoc)
|
83
185
|
})
|
84
186
|
)
|
187
|
+
this.syncRootComponent(root, rootComponent)
|
85
188
|
}
|
86
189
|
|
87
190
|
updateRemoteSelection(paths: RemoteSelection[]) {
|
88
191
|
this.collaborateCursor.draw(paths)
|
89
192
|
}
|
90
193
|
|
91
|
-
listen() {
|
92
|
-
//
|
93
|
-
}
|
94
|
-
|
95
194
|
back() {
|
96
195
|
if (this.canBack) {
|
97
|
-
this.
|
98
|
-
this.
|
99
|
-
this.scheduler.ignoreChanges = false
|
196
|
+
this.manager?.undo()
|
197
|
+
this.backEvent.next()
|
100
198
|
}
|
101
199
|
}
|
102
200
|
|
103
201
|
forward() {
|
104
202
|
if (this.canForward) {
|
105
|
-
this.
|
106
|
-
this.
|
107
|
-
this.scheduler.ignoreChanges = false
|
203
|
+
this.manager?.redo()
|
204
|
+
this.forwardEvent.next()
|
108
205
|
}
|
109
206
|
}
|
110
207
|
|
208
|
+
clear() {
|
209
|
+
this.manager?.clear()
|
210
|
+
this.changeEvent.next()
|
211
|
+
}
|
212
|
+
|
111
213
|
destroy() {
|
112
214
|
this.subscriptions.forEach(i => i.unsubscribe())
|
215
|
+
this.collaborateCursor.destroy()
|
216
|
+
this.manager?.destroy()
|
113
217
|
}
|
114
218
|
|
115
|
-
private
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
this.subscriptions.push(
|
124
|
-
this.scheduler.onDocChange.subscribe(() => {
|
125
|
-
this.yDoc.transact(() => {
|
126
|
-
this.updateRemoteActions.forEach(fn => {
|
127
|
-
fn()
|
128
|
-
})
|
129
|
-
this.updateRemoteActions = []
|
130
|
-
}, this.yDoc)
|
219
|
+
private syncRootComponent(root: YMap<any>, rootComponent: ComponentInstance) {
|
220
|
+
let slots = root.get('slots') as YArray<YMap<any>>
|
221
|
+
if (!slots) {
|
222
|
+
slots = new YArray()
|
223
|
+
rootComponent.slots.toArray().forEach(i => {
|
224
|
+
const sharedSlot = this.createSharedSlotBySlot(i)
|
225
|
+
slots.push([sharedSlot])
|
131
226
|
})
|
132
|
-
|
227
|
+
this.yDoc.transact(() => {
|
228
|
+
root.set('state', rootComponent.state)
|
229
|
+
root.set('slots', slots)
|
230
|
+
})
|
231
|
+
} else if (slots.length === 0) {
|
232
|
+
rootComponent.updateState(() => {
|
233
|
+
return root.get('state')
|
234
|
+
})
|
235
|
+
this.yDoc.transact(() => {
|
236
|
+
rootComponent.slots.toArray().forEach(i => {
|
237
|
+
const sharedSlot = this.createSharedSlotBySlot(i)
|
238
|
+
slots.push([sharedSlot])
|
239
|
+
})
|
240
|
+
})
|
241
|
+
} else {
|
242
|
+
rootComponent.updateState(() => {
|
243
|
+
return root.get('state')
|
244
|
+
})
|
245
|
+
rootComponent.slots.clean()
|
246
|
+
slots.forEach(sharedSlot => {
|
247
|
+
const slot = this.createSlotBySharedSlot(sharedSlot)
|
248
|
+
this.syncContent(sharedSlot.get('content'), slot)
|
249
|
+
this.syncSlot(sharedSlot, slot)
|
250
|
+
rootComponent.slots.insert(slot)
|
251
|
+
})
|
252
|
+
}
|
253
|
+
this.syncComponent(root, rootComponent)
|
254
|
+
this.syncSlots(slots, rootComponent)
|
255
|
+
}
|
256
|
+
|
257
|
+
private restoreCursorLocation(position: CursorPosition) {
|
258
|
+
const anchorPosition = createAbsolutePositionFromRelativePosition(position.anchor, this.yDoc)
|
259
|
+
const focusPosition = createAbsolutePositionFromRelativePosition(position.focus, this.yDoc)
|
260
|
+
if (anchorPosition && focusPosition) {
|
261
|
+
const focusSlot = this.contentMap.get(focusPosition.type as YText)
|
262
|
+
const anchorSlot = this.contentMap.get(anchorPosition.type as YText)
|
263
|
+
if (focusSlot && anchorSlot) {
|
264
|
+
this.selection.setBaseAndExtent(anchorSlot, anchorPosition.index, focusSlot, focusPosition.index)
|
265
|
+
return
|
266
|
+
}
|
267
|
+
}
|
268
|
+
this.selection.unSelect()
|
269
|
+
}
|
270
|
+
|
271
|
+
private getRelativeCursorLocation(): CursorPosition | null {
|
272
|
+
const { anchorSlot, anchorOffset, focusSlot, focusOffset } = this.selection
|
273
|
+
if (anchorSlot) {
|
274
|
+
const anchorYText = this.contentMap.get(anchorSlot)
|
275
|
+
if (anchorYText) {
|
276
|
+
const anchorPosition = createRelativePositionFromTypeIndex(anchorYText, anchorOffset!)
|
277
|
+
if (focusSlot) {
|
278
|
+
const focusYText = this.contentMap.get(focusSlot)
|
279
|
+
if (focusYText) {
|
280
|
+
const focusPosition = createRelativePositionFromTypeIndex(focusYText, focusOffset!)
|
281
|
+
return {
|
282
|
+
focus: focusPosition,
|
283
|
+
anchor: anchorPosition
|
284
|
+
}
|
285
|
+
}
|
286
|
+
}
|
287
|
+
}
|
288
|
+
}
|
289
|
+
return null
|
133
290
|
}
|
134
291
|
|
135
292
|
private syncContent(content: YText, slot: Slot) {
|
293
|
+
this.contentMap.set(slot, content)
|
136
294
|
const syncRemote = (ev, tr) => {
|
137
295
|
this.runRemoteUpdate(tr, () => {
|
138
296
|
slot.retain(0)
|
@@ -143,6 +301,7 @@ export class Collaborate implements History {
|
|
143
301
|
if (formats.length) {
|
144
302
|
slot.retain(action.retain!, formats)
|
145
303
|
}
|
304
|
+
slot.retain(slot.index + action.retain)
|
146
305
|
} else {
|
147
306
|
slot.retain(action.retain)
|
148
307
|
}
|
@@ -161,11 +320,11 @@ export class Collaborate implements History {
|
|
161
320
|
slot.insert(component)
|
162
321
|
}
|
163
322
|
if (this.selection.isSelected) {
|
164
|
-
if (slot === this.selection.
|
165
|
-
this.selection.
|
323
|
+
if (slot === this.selection.anchorSlot && this.selection.anchorOffset! > index) {
|
324
|
+
this.selection.setAnchor(slot, this.selection.anchorOffset! + length)
|
166
325
|
}
|
167
|
-
if (slot === this.selection.
|
168
|
-
this.selection.
|
326
|
+
if (slot === this.selection.focusSlot && this.selection.focusOffset! > index) {
|
327
|
+
this.selection.setFocus(slot, this.selection.focusOffset! + length)
|
169
328
|
}
|
170
329
|
}
|
171
330
|
} else if (action.delete) {
|
@@ -173,16 +332,20 @@ export class Collaborate implements History {
|
|
173
332
|
slot.retain(slot.index)
|
174
333
|
slot.delete(action.delete)
|
175
334
|
if (this.selection.isSelected) {
|
176
|
-
if (slot === this.selection.
|
177
|
-
this.selection.
|
335
|
+
if (slot === this.selection.anchorSlot && this.selection.anchorOffset! >= index) {
|
336
|
+
this.selection.setAnchor(slot, this.selection.startOffset! - action.delete)
|
178
337
|
}
|
179
|
-
if (slot === this.selection.
|
180
|
-
this.selection.
|
338
|
+
if (slot === this.selection.focusSlot && this.selection.focusOffset! >= index) {
|
339
|
+
this.selection.setFocus(slot, this.selection.focusOffset! - action.delete)
|
181
340
|
}
|
182
341
|
}
|
183
342
|
} else if (action.attributes) {
|
184
343
|
slot.updateState(draft => {
|
185
|
-
|
344
|
+
if (typeof draft === 'object' && draft !== null) {
|
345
|
+
Object.assign(draft, action.attributes)
|
346
|
+
} else {
|
347
|
+
return action.attributes
|
348
|
+
}
|
186
349
|
})
|
187
350
|
}
|
188
351
|
})
|
@@ -217,23 +380,22 @@ export class Collaborate implements History {
|
|
217
380
|
const isEmpty = delta.length === 1 && delta[0].insert === Slot.emptyPlaceholder
|
218
381
|
if (typeof action.content === 'string') {
|
219
382
|
length = action.content.length
|
220
|
-
content.insert(offset, action.content)
|
383
|
+
content.insert(offset, action.content, action.formats || {})
|
221
384
|
} else {
|
222
385
|
length = 1
|
223
|
-
const
|
224
|
-
const sharedComponent = this.createSharedComponentByComponent(component)
|
386
|
+
const sharedComponent = this.createSharedComponentByComponent(action.ref as ComponentInstance)
|
225
387
|
content.insertEmbed(offset, sharedComponent)
|
226
388
|
}
|
227
|
-
|
228
|
-
content.format(offset, length, action.formats)
|
229
|
-
}
|
389
|
+
|
230
390
|
if (isEmpty && offset === 0) {
|
231
391
|
content.delete(content.length - 1, 1)
|
232
392
|
}
|
233
393
|
offset += length
|
234
394
|
} else if (action.type === 'delete') {
|
235
395
|
const delta = content.toDelta()
|
236
|
-
content.
|
396
|
+
if (content.length) {
|
397
|
+
content.delete(offset, action.count)
|
398
|
+
}
|
237
399
|
if (content.length === 0) {
|
238
400
|
content.insert(0, '\n', delta[0]?.attributes)
|
239
401
|
}
|
@@ -260,7 +422,11 @@ export class Collaborate implements History {
|
|
260
422
|
if (key === 'state') {
|
261
423
|
const state = (ev.target as YMap<any>).get('state')
|
262
424
|
slot.updateState(draft => {
|
263
|
-
|
425
|
+
if (typeof draft === 'object' && draft !== null) {
|
426
|
+
Object.assign(draft, state)
|
427
|
+
} else {
|
428
|
+
return state
|
429
|
+
}
|
264
430
|
})
|
265
431
|
}
|
266
432
|
})
|
@@ -288,8 +454,8 @@ export class Collaborate implements History {
|
|
288
454
|
let index = 0
|
289
455
|
ev.delta.forEach(action => {
|
290
456
|
if (Reflect.has(action, 'retain')) {
|
291
|
-
slots.retain(action.retain!)
|
292
457
|
index += action.retain
|
458
|
+
slots.retain(index)
|
293
459
|
} else if (action.insert) {
|
294
460
|
(action.insert as Array<YMap<any>>).forEach(item => {
|
295
461
|
const slot = this.createSlotBySharedSlot(item)
|
@@ -315,8 +481,7 @@ export class Collaborate implements History {
|
|
315
481
|
if (action.type === 'retain') {
|
316
482
|
index = action.offset
|
317
483
|
} else if (action.type === 'insertSlot') {
|
318
|
-
const
|
319
|
-
const sharedSlot = this.createSharedSlotBySlot(slot)
|
484
|
+
const sharedSlot = this.createSharedSlotBySlot(action.ref)
|
320
485
|
remoteSlots.insert(index, [sharedSlot])
|
321
486
|
index++
|
322
487
|
} else if (action.type === 'delete') {
|
@@ -345,7 +510,11 @@ export class Collaborate implements History {
|
|
345
510
|
if (key === 'state') {
|
346
511
|
const state = (ev.target as YMap<any>).get('state')
|
347
512
|
component.updateState(draft => {
|
348
|
-
|
513
|
+
if (typeof draft === 'object' && draft !== null) {
|
514
|
+
Object.assign(draft, state)
|
515
|
+
} else {
|
516
|
+
return state
|
517
|
+
}
|
349
518
|
})
|
350
519
|
}
|
351
520
|
})
|
@@ -365,7 +534,7 @@ export class Collaborate implements History {
|
|
365
534
|
}
|
366
535
|
|
367
536
|
private runLocalUpdate(fn: () => void) {
|
368
|
-
if (this.updateFromRemote) {
|
537
|
+
if (this.updateFromRemote || this.controller.readonly) {
|
369
538
|
return
|
370
539
|
}
|
371
540
|
this.updateRemoteActions.push(fn)
|
@@ -376,7 +545,11 @@ export class Collaborate implements History {
|
|
376
545
|
return
|
377
546
|
}
|
378
547
|
this.updateFromRemote = true
|
379
|
-
|
548
|
+
if (tr.origin === this.manager) {
|
549
|
+
this.scheduler.historyApplyTransact(fn)
|
550
|
+
} else {
|
551
|
+
this.scheduler.remoteUpdateTransact(fn)
|
552
|
+
}
|
380
553
|
this.updateFromRemote = false
|
381
554
|
}
|
382
555
|
|
@@ -432,10 +605,17 @@ export class Collaborate implements History {
|
|
432
605
|
slots.push(slot)
|
433
606
|
})
|
434
607
|
const name = yMap.get('name')
|
435
|
-
const
|
436
|
-
|
608
|
+
const state = yMap.get('state')
|
609
|
+
let instance = this.translator.createComponentByData(name, {
|
610
|
+
state,
|
437
611
|
slots
|
438
612
|
})
|
613
|
+
if (!instance) {
|
614
|
+
instance = this.translatorFallback.createComponentByData(name, {
|
615
|
+
state,
|
616
|
+
slots
|
617
|
+
})
|
618
|
+
}
|
439
619
|
if (instance) {
|
440
620
|
instance.slots.toArray().forEach((slot, index) => {
|
441
621
|
let sharedSlot = sharedSlots.get(index)
|
@@ -482,6 +662,7 @@ export class Collaborate implements History {
|
|
482
662
|
}
|
483
663
|
|
484
664
|
private cleanSubscriptionsBySlot(slot: Slot) {
|
665
|
+
this.contentMap.delete(slot);
|
485
666
|
[this.contentSyncCaches.get(slot), this.slotStateSyncCaches.get(slot)].forEach(fn => {
|
486
667
|
if (fn) {
|
487
668
|
fn()
|
@@ -509,7 +690,7 @@ export class Collaborate implements History {
|
|
509
690
|
function makeFormats(registry: Registry, attrs?: any) {
|
510
691
|
const formats: Formats = []
|
511
692
|
if (attrs) {
|
512
|
-
Object.keys(attrs).
|
693
|
+
Object.keys(attrs).forEach(key => {
|
513
694
|
const formatter = registry.getFormatter(key)
|
514
695
|
if (formatter) {
|
515
696
|
formats.push([formatter, attrs[key]])
|