@textbus/collaborate 2.0.0-beta.5 → 2.0.0-beta.50

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,27 +1,85 @@
1
- import { Injectable } from '@tanbo/di'
2
- import { merge, microTask, Observable, Subject, Subscription } from '@tanbo/stream'
1
+ import { Inject, Injectable } from '@tanbo/di'
2
+ import { delay, filter, map, Observable, Subject, Subscription } from '@tanbo/stream'
3
3
  import {
4
- RootComponentRef,
5
- Starter,
6
- Translator,
4
+ ChangeOrigin,
5
+ ComponentInstance,
6
+ ContentType, Controller,
7
+ Formats,
8
+ History, HISTORY_STACK_SIZE,
9
+ makeError,
7
10
  Registry,
11
+ RootComponentRef,
12
+ Scheduler,
8
13
  Selection,
9
14
  SelectionPaths,
10
- History, Renderer, Slot, ComponentInstance, makeError, Formats
15
+ Slot,
16
+ Starter,
17
+ Translator
11
18
  } from '@textbus/core'
12
19
  import {
20
+ Array as YArray,
13
21
  Doc as YDoc,
14
22
  Map as YMap,
23
+ RelativePosition,
15
24
  Text as YText,
16
- Array as YArray,
25
+ Transaction,
17
26
  UndoManager,
18
- Transaction
27
+ createAbsolutePositionFromRelativePosition,
28
+ createRelativePositionFromTypeIndex
19
29
  } from 'yjs'
20
30
 
21
31
  import { CollaborateCursor, RemoteSelection } from './collaborate-cursor'
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
+
25
83
  @Injectable()
26
84
  export class Collaborate implements History {
27
85
  onSelectionChange: Observable<SelectionPaths>
@@ -32,11 +90,11 @@ export class Collaborate implements History {
32
90
  onPush: Observable<void>
33
91
 
34
92
  get canBack() {
35
- return this.manager?.canUndo()
93
+ return this.manager?.canUndo() || false
36
94
  }
37
95
 
38
96
  get canForward() {
39
- return this.manager?.canRedo()
97
+ return this.manager?.canRedo() || false
40
98
  }
41
99
 
42
100
  private backEvent = new Subject<void>()
@@ -44,7 +102,7 @@ export class Collaborate implements History {
44
102
  private changeEvent = new Subject<void>()
45
103
  private pushEvent = new Subject<void>()
46
104
 
47
- private manager!: UndoManager
105
+ private manager: UndoManager | null = null
48
106
 
49
107
  private subscriptions: Subscription[] = []
50
108
  private updateFromRemote = false
@@ -55,17 +113,20 @@ export class Collaborate implements History {
55
113
  private componentStateSyncCaches = new WeakMap<ComponentInstance, () => void>()
56
114
 
57
115
  private selectionChangeEvent = new Subject<SelectionPaths>()
116
+ private contentMap = new ContentMap()
58
117
 
59
118
  private updateRemoteActions: Array<() => void> = []
60
119
 
61
- constructor(private rootComponentRef: RootComponentRef,
120
+ constructor(@Inject(HISTORY_STACK_SIZE) private stackSize: number,
121
+ private rootComponentRef: RootComponentRef,
62
122
  private collaborateCursor: CollaborateCursor,
123
+ private controller: Controller,
124
+ private scheduler: Scheduler,
63
125
  private translator: Translator,
64
- private renderer: Renderer,
65
126
  private registry: Registry,
66
127
  private selection: Selection,
67
128
  private starter: Starter) {
68
- this.onSelectionChange = this.selectionChangeEvent.asObservable()
129
+ this.onSelectionChange = this.selectionChangeEvent.asObservable().pipe(delay())
69
130
  this.onBack = this.backEvent.asObservable()
70
131
  this.onForward = this.forwardEvent.asObservable()
71
132
  this.onChange = this.changeEvent.asObservable()
@@ -74,14 +135,12 @@ export class Collaborate implements History {
74
135
 
75
136
  setup() {
76
137
  this.subscriptions.push(
77
- this.starter.onReady.subscribe(() => {
78
- this.listen2()
79
- }),
80
138
  this.selection.onChange.subscribe(() => {
81
139
  const paths = this.selection.getPaths()
82
140
  this.selectionChangeEvent.next(paths)
83
141
  })
84
142
  )
143
+ this.syncRootComponent()
85
144
  }
86
145
 
87
146
  updateRemoteSelection(paths: RemoteSelection[]) {
@@ -94,34 +153,101 @@ export class Collaborate implements History {
94
153
 
95
154
  back() {
96
155
  if (this.canBack) {
97
- this.manager.undo()
156
+ this.manager?.undo()
157
+ this.backEvent.next()
98
158
  }
99
159
  }
100
160
 
101
161
  forward() {
102
162
  if (this.canForward) {
103
- this.manager.redo()
163
+ this.manager?.redo()
164
+ this.forwardEvent.next()
104
165
  }
105
166
  }
106
167
 
168
+ clear() {
169
+ this.manager?.clear()
170
+ this.changeEvent.next()
171
+ }
172
+
107
173
  destroy() {
108
174
  this.subscriptions.forEach(i => i.unsubscribe())
175
+ this.collaborateCursor.destroy()
176
+ this.manager?.destroy()
109
177
  }
110
178
 
111
- private listen2() {
112
- const root = this.yDoc.getText('content')
179
+ private syncRootComponent() {
180
+ const root = this.yDoc.getMap('RootComponent')
113
181
  const rootComponent = this.rootComponentRef.component!
114
182
  this.manager = new UndoManager(root, {
115
183
  trackedOrigins: new Set<any>([this.yDoc])
116
184
  })
117
- this.syncContent(root, rootComponent.slots.get(0)!)
185
+ const cursorKey = 'cursor-position'
186
+ this.manager.on('stack-item-added', event => {
187
+ event.stackItem.meta.set(cursorKey, this.getRelativeCursorLocation())
188
+ if (this.manager!.undoStack.length > this.stackSize) {
189
+ this.manager!.undoStack.shift()
190
+ }
191
+ if (event.origin === this.yDoc) {
192
+ this.pushEvent.next()
193
+ }
194
+ this.changeEvent.next()
195
+ })
196
+ this.manager.on('stack-item-popped', event => {
197
+ const position = event.stackItem.meta.get(cursorKey) as CursorPosition
198
+ if (position) {
199
+ this.restoreCursorLocation(position)
200
+ }
201
+ })
202
+
203
+ this.yDoc.once('update', () => {
204
+ let slots = root.get('slots') as YArray<YMap<any>>
205
+ if (!slots) {
206
+ slots = new YArray()
207
+ rootComponent.slots.toArray().forEach(i => {
208
+ const sharedSlot = this.createSharedSlotBySlot(i)
209
+ slots.push([sharedSlot])
210
+ })
211
+ this.yDoc.transact(() => {
212
+ root.set('state', rootComponent.state)
213
+ root.set('slots', slots)
214
+ })
215
+ } else if (slots.length === 0) {
216
+ rootComponent.updateState(() => {
217
+ return root.get('state')
218
+ })
219
+ this.yDoc.transact(() => {
220
+ rootComponent.slots.toArray().forEach(i => {
221
+ const sharedSlot = this.createSharedSlotBySlot(i)
222
+ slots.push([sharedSlot])
223
+ })
224
+ })
225
+ } else {
226
+ rootComponent.updateState(() => {
227
+ return root.get('state')
228
+ })
229
+ rootComponent.slots.clean()
230
+ slots.forEach(sharedSlot => {
231
+ const slot = this.createSlotBySharedSlot(sharedSlot)
232
+ this.syncContent(sharedSlot.get('content'), slot)
233
+ this.syncSlot(sharedSlot, slot)
234
+ rootComponent.slots.insert(slot)
235
+ })
236
+ }
237
+ this.syncComponent(root, rootComponent)
238
+ this.syncSlots(slots, rootComponent)
239
+ })
118
240
 
119
241
  this.subscriptions.push(
120
- merge(
121
- rootComponent.changeMarker.onForceChange,
122
- rootComponent.changeMarker.onChange
123
- ).pipe(
124
- microTask()
242
+ this.scheduler.onDocChanged.pipe(
243
+ map(item => {
244
+ return item.filter(i => {
245
+ return i.from !== ChangeOrigin.Remote
246
+ })
247
+ }),
248
+ filter(item => {
249
+ return item.length
250
+ })
125
251
  ).subscribe(() => {
126
252
  this.yDoc.transact(() => {
127
253
  this.updateRemoteActions.forEach(fn => {
@@ -129,13 +255,45 @@ export class Collaborate implements History {
129
255
  })
130
256
  this.updateRemoteActions = []
131
257
  }, this.yDoc)
132
- this.renderer.render()
133
- this.selection.restore()
134
258
  })
135
259
  )
136
260
  }
137
261
 
262
+ private restoreCursorLocation(position: CursorPosition) {
263
+ const anchorPosition = createAbsolutePositionFromRelativePosition(position.anchor, this.yDoc)
264
+ const focusPosition = createAbsolutePositionFromRelativePosition(position.focus, this.yDoc)
265
+ if (anchorPosition && focusPosition) {
266
+ const focusSlot = this.contentMap.get(focusPosition.type as YText)
267
+ const anchorSlot = this.contentMap.get(anchorPosition.type as YText)
268
+ if (focusSlot && anchorSlot) {
269
+ this.selection.setBaseAndExtent(anchorSlot, anchorPosition.index, focusSlot, focusPosition.index)
270
+ }
271
+ }
272
+ }
273
+
274
+ private getRelativeCursorLocation(): CursorPosition | null {
275
+ const { anchorSlot, anchorOffset, focusSlot, focusOffset } = this.selection
276
+ if (anchorSlot) {
277
+ const anchorYText = this.contentMap.get(anchorSlot)
278
+ if (anchorYText) {
279
+ const anchorPosition = createRelativePositionFromTypeIndex(anchorYText, anchorOffset!)
280
+ if (focusSlot) {
281
+ const focusYText = this.contentMap.get(focusSlot)
282
+ if (focusYText) {
283
+ const focusPosition = createRelativePositionFromTypeIndex(focusYText, focusOffset!)
284
+ return {
285
+ focus: focusPosition,
286
+ anchor: anchorPosition
287
+ }
288
+ }
289
+ }
290
+ }
291
+ }
292
+ return null
293
+ }
294
+
138
295
  private syncContent(content: YText, slot: Slot) {
296
+ this.contentMap.set(slot, content)
139
297
  const syncRemote = (ev, tr) => {
140
298
  this.runRemoteUpdate(tr, () => {
141
299
  slot.retain(0)
@@ -157,17 +315,18 @@ export class Collaborate implements History {
157
315
  slot.insert(action.insert, makeFormats(this.registry, action.attributes))
158
316
  } else {
159
317
  const sharedComponent = action.insert as YMap<any>
160
- const component = this.createComponentBySharedComponent(sharedComponent)
318
+ const canInsertInlineComponent = slot.schema.includes(ContentType.InlineComponent)
319
+ const component = this.createComponentBySharedComponent(sharedComponent, canInsertInlineComponent)
161
320
  this.syncSlots(sharedComponent.get('slots'), component)
162
321
  this.syncComponent(sharedComponent, component)
163
322
  slot.insert(component)
164
323
  }
165
324
  if (this.selection.isSelected) {
166
- if (slot === this.selection.startSlot && this.selection.startOffset! >= index) {
167
- this.selection.setStart(slot, this.selection.startOffset! + length)
325
+ if (slot === this.selection.anchorSlot && this.selection.anchorOffset! > index) {
326
+ this.selection.setAnchor(slot, this.selection.anchorOffset! + length)
168
327
  }
169
- if (slot === this.selection.endSlot && this.selection.endOffset! >= index) {
170
- this.selection.setEnd(slot, this.selection.endOffset! + length)
328
+ if (slot === this.selection.focusSlot && this.selection.focusOffset! > index) {
329
+ this.selection.setFocus(slot, this.selection.focusOffset! + length)
171
330
  }
172
331
  }
173
332
  } else if (action.delete) {
@@ -175,11 +334,11 @@ export class Collaborate implements History {
175
334
  slot.retain(slot.index)
176
335
  slot.delete(action.delete)
177
336
  if (this.selection.isSelected) {
178
- if (slot === this.selection.startSlot && this.selection.startOffset! >= index) {
179
- this.selection.setStart(slot, this.selection.startOffset! - action.delete)
337
+ if (slot === this.selection.anchorSlot && this.selection.anchorOffset! >= index) {
338
+ this.selection.setAnchor(slot, this.selection.startOffset! - action.delete)
180
339
  }
181
- if (slot === this.selection.endSlot && this.selection.endOffset! >= index) {
182
- this.selection.setEnd(slot, this.selection.endOffset! - action.delete)
340
+ if (slot === this.selection.focusSlot && this.selection.focusOffset! >= index) {
341
+ this.selection.setFocus(slot, this.selection.focusOffset! - action.delete)
183
342
  }
184
343
  }
185
344
  } else if (action.attributes) {
@@ -219,23 +378,22 @@ export class Collaborate implements History {
219
378
  const isEmpty = delta.length === 1 && delta[0].insert === Slot.emptyPlaceholder
220
379
  if (typeof action.content === 'string') {
221
380
  length = action.content.length
222
- content.insert(offset, action.content)
381
+ content.insert(offset, action.content, action.formats || {})
223
382
  } else {
224
383
  length = 1
225
- const component = slot.getContentAtIndex(offset) as ComponentInstance
226
- const sharedComponent = this.createSharedComponentByComponent(component)
384
+ const sharedComponent = this.createSharedComponentByComponent(action.ref as ComponentInstance)
227
385
  content.insertEmbed(offset, sharedComponent)
228
386
  }
229
- if (action.formats) {
230
- content.format(offset, length, action.formats)
231
- }
387
+
232
388
  if (isEmpty && offset === 0) {
233
389
  content.delete(content.length - 1, 1)
234
390
  }
235
391
  offset += length
236
392
  } else if (action.type === 'delete') {
237
393
  const delta = content.toDelta()
238
- content.delete(offset, action.count)
394
+ if (content.length) {
395
+ content.delete(offset, action.count)
396
+ }
239
397
  if (content.length === 0) {
240
398
  content.insert(0, '\n', delta[0]?.attributes)
241
399
  }
@@ -243,6 +401,12 @@ export class Collaborate implements History {
243
401
  }
244
402
  })
245
403
  })
404
+
405
+ sub.add(slot.onChildComponentRemove.subscribe(components => {
406
+ components.forEach(c => {
407
+ this.cleanSubscriptionsByComponent(c)
408
+ })
409
+ }))
246
410
  this.contentSyncCaches.set(slot, () => {
247
411
  content.unobserve(syncRemote)
248
412
  sub.unsubscribe()
@@ -281,18 +445,21 @@ export class Collaborate implements History {
281
445
  const slots = component.slots
282
446
  const syncRemote = (ev, tr) => {
283
447
  this.runRemoteUpdate(tr, () => {
448
+ let index = 0
284
449
  ev.delta.forEach(action => {
285
450
  if (Reflect.has(action, 'retain')) {
286
- slots.retain(action.retain!)
451
+ index += action.retain
452
+ slots.retain(index)
287
453
  } else if (action.insert) {
288
454
  (action.insert as Array<YMap<any>>).forEach(item => {
289
455
  const slot = this.createSlotBySharedSlot(item)
290
456
  slots.insert(slot)
291
457
  this.syncContent(item.get('content'), slot)
292
458
  this.syncSlot(item, slot)
459
+ index++
293
460
  })
294
461
  } else if (action.delete) {
295
- slots.retain(slots.index)
462
+ slots.retain(index)
296
463
  slots.delete(action.delete)
297
464
  }
298
465
  })
@@ -308,20 +475,22 @@ export class Collaborate implements History {
308
475
  if (action.type === 'retain') {
309
476
  index = action.offset
310
477
  } else if (action.type === 'insertSlot') {
311
- const slot = slots.get(index)!
312
- const sharedSlot = this.createSharedSlotBySlot(slot)
478
+ const sharedSlot = this.createSharedSlotBySlot(action.ref)
313
479
  remoteSlots.insert(index, [sharedSlot])
314
480
  index++
315
481
  } else if (action.type === 'delete') {
316
- slots.slice(index, index + action.count).forEach(slot => {
317
- this.cleanSubscriptionsBySlot(slot)
318
- })
319
482
  remoteSlots.delete(index, action.count)
320
483
  }
321
484
  })
322
485
  })
323
486
  })
324
487
 
488
+ sub.add(slots.onChildSlotRemove.subscribe(slots => {
489
+ slots.forEach(slot => {
490
+ this.cleanSubscriptionsBySlot(slot)
491
+ })
492
+ }))
493
+
325
494
  this.slotsSyncCaches.set(component, () => {
326
495
  remoteSlots.unobserve(syncRemote)
327
496
  sub.unsubscribe()
@@ -355,7 +524,7 @@ export class Collaborate implements History {
355
524
  }
356
525
 
357
526
  private runLocalUpdate(fn: () => void) {
358
- if (this.updateFromRemote) {
527
+ if (this.updateFromRemote || this.controller.readonly) {
359
528
  return
360
529
  }
361
530
  this.updateRemoteActions.push(fn)
@@ -366,7 +535,11 @@ export class Collaborate implements History {
366
535
  return
367
536
  }
368
537
  this.updateFromRemote = true
369
- fn()
538
+ if (tr.origin === this.manager) {
539
+ this.scheduler.historyApplyTransact(fn)
540
+ } else {
541
+ this.scheduler.remoteUpdateTransact(fn)
542
+ }
370
543
  this.updateFromRemote = false
371
544
  }
372
545
 
@@ -414,7 +587,7 @@ export class Collaborate implements History {
414
587
  return sharedSlot
415
588
  }
416
589
 
417
- private createComponentBySharedComponent(yMap: YMap<any>): ComponentInstance {
590
+ private createComponentBySharedComponent(yMap: YMap<any>, canInsertInlineComponent: boolean): ComponentInstance {
418
591
  const sharedSlots = yMap.get('slots') as YArray<YMap<any>>
419
592
  const slots: Slot[] = []
420
593
  sharedSlots.forEach(sharedSlot => {
@@ -428,13 +601,17 @@ export class Collaborate implements History {
428
601
  })
429
602
  if (instance) {
430
603
  instance.slots.toArray().forEach((slot, index) => {
431
- const sharedSlot = sharedSlots.get(index)
604
+ let sharedSlot = sharedSlots.get(index)
605
+ if (!sharedSlot) {
606
+ sharedSlot = this.createSharedSlotBySlot(slot)
607
+ sharedSlots.push([sharedSlot])
608
+ }
432
609
  this.syncSlot(sharedSlot, slot)
433
610
  this.syncContent(sharedSlot.get('content'), slot)
434
611
  })
435
612
  return instance
436
613
  }
437
- throw collaborateErrorFn(`cannot find component factory \`${name}\`.`)
614
+ return createUnknownComponent(name, canInsertInlineComponent).createInstance(this.starter)
438
615
  }
439
616
 
440
617
  private createSlotBySharedSlot(sharedSlot: YMap<any>): Slot {
@@ -454,7 +631,8 @@ export class Collaborate implements History {
454
631
  slot.insert(action.insert, makeFormats(this.registry, action.attributes))
455
632
  } else {
456
633
  const sharedComponent = action.insert as YMap<any>
457
- const component = this.createComponentBySharedComponent(sharedComponent)
634
+ const canInsertInlineComponent = slot.schema.includes(ContentType.InlineComponent)
635
+ const component = this.createComponentBySharedComponent(sharedComponent, canInsertInlineComponent)
458
636
  slot.insert(component)
459
637
  this.syncSlots(sharedComponent.get('slots'), component)
460
638
  this.syncComponent(sharedComponent, component)
@@ -467,6 +645,7 @@ export class Collaborate implements History {
467
645
  }
468
646
 
469
647
  private cleanSubscriptionsBySlot(slot: Slot) {
648
+ this.contentMap.delete(slot);
470
649
  [this.contentSyncCaches.get(slot), this.slotStateSyncCaches.get(slot)].forEach(fn => {
471
650
  if (fn) {
472
651
  fn()
@@ -494,7 +673,7 @@ export class Collaborate implements History {
494
673
  function makeFormats(registry: Registry, attrs?: any) {
495
674
  const formats: Formats = []
496
675
  if (attrs) {
497
- Object.keys(attrs).map(key => {
676
+ Object.keys(attrs).forEach(key => {
498
677
  const formatter = registry.getFormatter(key)
499
678
  if (formatter) {
500
679
  formats.push([formatter, attrs[key]])
package/src/public-api.ts CHANGED
@@ -1,2 +1,18 @@
1
+ import { History, Module } from '@textbus/core'
2
+
3
+ import { Collaborate } from './collaborate'
4
+ import { CollaborateCursor } from './collaborate-cursor'
5
+
1
6
  export * from './collaborate'
2
7
  export * from './collaborate-cursor'
8
+
9
+ export const collaborateModule: Module = {
10
+ providers: [
11
+ Collaborate,
12
+ CollaborateCursor,
13
+ {
14
+ provide: History,
15
+ useClass: Collaborate
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,22 @@
1
+ import { ContentType, defineComponent, VElement } from '@textbus/core'
2
+
3
+ export function createUnknownComponent(factoryName: string, canInsertInlineComponent: boolean) {
4
+ const unknownComponent = defineComponent({
5
+ type: canInsertInlineComponent ? ContentType.InlineComponent : ContentType.BlockComponent,
6
+ name: 'UnknownComponent',
7
+ setup() {
8
+ console.error(`cannot find component factory \`${factoryName}\`.`)
9
+ return {
10
+ render() {
11
+ return VElement.createElement('textbus-unknown-component', {
12
+ style: {
13
+ display: canInsertInlineComponent ? 'inline' : 'block',
14
+ color: '#f00'
15
+ }
16
+ }, unknownComponent.name)
17
+ }
18
+ }
19
+ }
20
+ })
21
+ return unknownComponent
22
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "declaration": true,
4
- "useDefineForClassFields": true,
4
+ "useDefineForClassFields": false,
5
5
  "emitDecoratorMetadata": true,
6
6
  "experimentalDecorators": true,
7
7
  "allowSyntheticDefaultImports": true,