@textbus/collaborate 2.0.0-beta.4 → 2.0.0-beta.40

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.
@@ -1,27 +1,83 @@
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, createAbsolutePositionFromRelativePosition, createRelativePositionFromTypeIndex,
13
21
  Doc as YDoc,
14
22
  Map as YMap,
23
+ RelativePosition,
15
24
  Text as YText,
16
- Array as YArray,
17
- UndoManager,
18
- Transaction
25
+ Transaction,
26
+ UndoManager
19
27
  } from 'yjs'
20
28
 
21
- import { CollaborateCursor, RemoteSelection } from './collab/_api'
29
+ import { CollaborateCursor, RemoteSelection } from './collaborate-cursor'
30
+ import { createUnknownComponent } from './unknown.component'
22
31
 
23
32
  const collaborateErrorFn = makeError('Collaborate')
24
33
 
34
+ interface CursorPosition {
35
+ anchor: RelativePosition
36
+ focus: RelativePosition
37
+ }
38
+
39
+ class ContentMap {
40
+ private slotAndYTextMap = new WeakMap<Slot, YText>()
41
+ private yTextAndSLotMap = new WeakMap<YText, Slot>()
42
+
43
+ set(key: Slot, value: YText): void
44
+ set(key: YText, value: Slot): void
45
+ set(key: any, value: any) {
46
+ if (key instanceof Slot) {
47
+ this.slotAndYTextMap.set(key, value)
48
+ this.yTextAndSLotMap.set(value, key)
49
+ } else {
50
+ this.slotAndYTextMap.set(value, key)
51
+ this.yTextAndSLotMap.set(key, value)
52
+ }
53
+ }
54
+
55
+ get(key: Slot): YText | null
56
+ get(key: YText): Slot | null
57
+ get(key: any) {
58
+ if (key instanceof Slot) {
59
+ return this.slotAndYTextMap.get(key) || null
60
+ }
61
+ return this.yTextAndSLotMap.get(key) || null
62
+ }
63
+
64
+ delete(key: Slot | YText) {
65
+ if (key instanceof Slot) {
66
+ const v = this.slotAndYTextMap.get(key)
67
+ this.slotAndYTextMap.delete(key)
68
+ if (v) {
69
+ this.yTextAndSLotMap.delete(v)
70
+ }
71
+ } else {
72
+ const v = this.yTextAndSLotMap.get(key)
73
+ this.yTextAndSLotMap.delete(key)
74
+ if (v) {
75
+ this.slotAndYTextMap.delete(v)
76
+ }
77
+ }
78
+ }
79
+ }
80
+
25
81
  @Injectable()
26
82
  export class Collaborate implements History {
27
83
  onSelectionChange: Observable<SelectionPaths>
@@ -32,11 +88,11 @@ export class Collaborate implements History {
32
88
  onPush: Observable<void>
33
89
 
34
90
  get canBack() {
35
- return this.manager?.canUndo()
91
+ return this.manager?.canUndo() || false
36
92
  }
37
93
 
38
94
  get canForward() {
39
- return this.manager?.canRedo()
95
+ return this.manager?.canRedo() || false
40
96
  }
41
97
 
42
98
  private backEvent = new Subject<void>()
@@ -44,7 +100,7 @@ export class Collaborate implements History {
44
100
  private changeEvent = new Subject<void>()
45
101
  private pushEvent = new Subject<void>()
46
102
 
47
- private manager!: UndoManager
103
+ private manager: UndoManager | null = null
48
104
 
49
105
  private subscriptions: Subscription[] = []
50
106
  private updateFromRemote = false
@@ -55,17 +111,20 @@ export class Collaborate implements History {
55
111
  private componentStateSyncCaches = new WeakMap<ComponentInstance, () => void>()
56
112
 
57
113
  private selectionChangeEvent = new Subject<SelectionPaths>()
114
+ private contentMap = new ContentMap()
58
115
 
59
116
  private updateRemoteActions: Array<() => void> = []
60
117
 
61
- constructor(private rootComponentRef: RootComponentRef,
118
+ constructor(@Inject(HISTORY_STACK_SIZE) private stackSize: number,
119
+ private rootComponentRef: RootComponentRef,
62
120
  private collaborateCursor: CollaborateCursor,
121
+ private controller: Controller,
122
+ private scheduler: Scheduler,
63
123
  private translator: Translator,
64
- private renderer: Renderer,
65
124
  private registry: Registry,
66
125
  private selection: Selection,
67
126
  private starter: Starter) {
68
- this.onSelectionChange = this.selectionChangeEvent.asObservable()
127
+ this.onSelectionChange = this.selectionChangeEvent.asObservable().pipe(delay())
69
128
  this.onBack = this.backEvent.asObservable()
70
129
  this.onForward = this.forwardEvent.asObservable()
71
130
  this.onChange = this.changeEvent.asObservable()
@@ -74,14 +133,12 @@ export class Collaborate implements History {
74
133
 
75
134
  setup() {
76
135
  this.subscriptions.push(
77
- this.starter.onReady.subscribe(() => {
78
- this.listen2()
79
- }),
80
136
  this.selection.onChange.subscribe(() => {
81
137
  const paths = this.selection.getPaths()
82
138
  this.selectionChangeEvent.next(paths)
83
139
  })
84
140
  )
141
+ this.syncRootComponent()
85
142
  }
86
143
 
87
144
  updateRemoteSelection(paths: RemoteSelection[]) {
@@ -94,34 +151,64 @@ export class Collaborate implements History {
94
151
 
95
152
  back() {
96
153
  if (this.canBack) {
97
- this.manager.undo()
154
+ this.manager?.undo()
155
+ this.backEvent.next()
98
156
  }
99
157
  }
100
158
 
101
159
  forward() {
102
160
  if (this.canForward) {
103
- this.manager.redo()
161
+ this.manager?.redo()
162
+ this.forwardEvent.next()
104
163
  }
105
164
  }
106
165
 
166
+ clear() {
167
+ this.manager?.clear()
168
+ this.changeEvent.next()
169
+ }
170
+
107
171
  destroy() {
108
172
  this.subscriptions.forEach(i => i.unsubscribe())
173
+ this.collaborateCursor.destroy()
174
+ this.manager?.destroy()
109
175
  }
110
176
 
111
- private listen2() {
177
+ private syncRootComponent() {
112
178
  const root = this.yDoc.getText('content')
113
179
  const rootComponent = this.rootComponentRef.component!
114
180
  this.manager = new UndoManager(root, {
115
181
  trackedOrigins: new Set<any>([this.yDoc])
116
182
  })
183
+ const cursorKey = 'cursor-position'
184
+ this.manager.on('stack-item-added', event => {
185
+ event.stackItem.meta.set(cursorKey, this.getRelativeCursorLocation())
186
+ if (this.manager!.undoStack.length > this.stackSize) {
187
+ this.manager!.undoStack.shift()
188
+ }
189
+ if (event.origin === this.yDoc) {
190
+ this.pushEvent.next()
191
+ }
192
+ this.changeEvent.next()
193
+ })
194
+ this.manager.on('stack-item-popped', event => {
195
+ const position = event.stackItem.meta.get(cursorKey) as CursorPosition
196
+ if (position) {
197
+ this.restoreCursorLocation(position)
198
+ }
199
+ })
117
200
  this.syncContent(root, rootComponent.slots.get(0)!)
118
201
 
119
202
  this.subscriptions.push(
120
- merge(
121
- rootComponent.changeMarker.onForceChange,
122
- rootComponent.changeMarker.onChange
123
- ).pipe(
124
- microTask()
203
+ this.scheduler.onDocChange.pipe(
204
+ map(item => {
205
+ return item.filter(i => {
206
+ return i.from === ChangeOrigin.Local
207
+ })
208
+ }),
209
+ filter(item => {
210
+ return item.length
211
+ })
125
212
  ).subscribe(() => {
126
213
  this.yDoc.transact(() => {
127
214
  this.updateRemoteActions.forEach(fn => {
@@ -129,13 +216,45 @@ export class Collaborate implements History {
129
216
  })
130
217
  this.updateRemoteActions = []
131
218
  }, this.yDoc)
132
- this.renderer.render()
133
- this.selection.restore()
134
219
  })
135
220
  )
136
221
  }
137
222
 
223
+ private restoreCursorLocation(position: CursorPosition) {
224
+ const anchorPosition = createAbsolutePositionFromRelativePosition(position.anchor, this.yDoc)
225
+ const focusPosition = createAbsolutePositionFromRelativePosition(position.focus, this.yDoc)
226
+ if (anchorPosition && focusPosition) {
227
+ const focusSlot = this.contentMap.get(focusPosition.type as YText)
228
+ const anchorSlot = this.contentMap.get(anchorPosition.type as YText)
229
+ if (focusSlot && anchorSlot) {
230
+ this.selection.setBaseAndExtent(anchorSlot, anchorPosition.index, focusSlot, focusPosition.index)
231
+ }
232
+ }
233
+ }
234
+
235
+ private getRelativeCursorLocation(): CursorPosition | null {
236
+ const {anchorSlot, anchorOffset, focusSlot, focusOffset} = this.selection
237
+ if (anchorSlot) {
238
+ const anchorYText = this.contentMap.get(anchorSlot)
239
+ if (anchorYText) {
240
+ const anchorPosition = createRelativePositionFromTypeIndex(anchorYText, anchorOffset!)
241
+ if (focusSlot) {
242
+ const focusYText = this.contentMap.get(focusSlot)
243
+ if (focusYText) {
244
+ const focusPosition = createRelativePositionFromTypeIndex(focusYText, focusOffset!)
245
+ return {
246
+ focus: focusPosition,
247
+ anchor: anchorPosition
248
+ }
249
+ }
250
+ }
251
+ }
252
+ }
253
+ return null
254
+ }
255
+
138
256
  private syncContent(content: YText, slot: Slot) {
257
+ this.contentMap.set(slot, content)
139
258
  const syncRemote = (ev, tr) => {
140
259
  this.runRemoteUpdate(tr, () => {
141
260
  slot.retain(0)
@@ -157,17 +276,18 @@ export class Collaborate implements History {
157
276
  slot.insert(action.insert, makeFormats(this.registry, action.attributes))
158
277
  } else {
159
278
  const sharedComponent = action.insert as YMap<any>
160
- const component = this.createComponentBySharedComponent(sharedComponent)
279
+ const canInsertInlineComponent = slot.schema.includes(ContentType.InlineComponent)
280
+ const component = this.createComponentBySharedComponent(sharedComponent, canInsertInlineComponent)
161
281
  this.syncSlots(sharedComponent.get('slots'), component)
162
282
  this.syncComponent(sharedComponent, component)
163
283
  slot.insert(component)
164
284
  }
165
285
  if (this.selection.isSelected) {
166
- if (slot === this.selection.startSlot && this.selection.startOffset! >= index) {
167
- this.selection.setStart(slot, this.selection.startOffset! + length)
286
+ if (slot === this.selection.anchorSlot && this.selection.anchorOffset! > index) {
287
+ this.selection.setAnchor(slot, this.selection.anchorOffset! + length)
168
288
  }
169
- if (slot === this.selection.endSlot && this.selection.endOffset! >= index) {
170
- this.selection.setEnd(slot, this.selection.endOffset! + length)
289
+ if (slot === this.selection.focusSlot && this.selection.focusOffset! > index) {
290
+ this.selection.setFocus(slot, this.selection.focusOffset! + length)
171
291
  }
172
292
  }
173
293
  } else if (action.delete) {
@@ -175,11 +295,11 @@ export class Collaborate implements History {
175
295
  slot.retain(slot.index)
176
296
  slot.delete(action.delete)
177
297
  if (this.selection.isSelected) {
178
- if (slot === this.selection.startSlot && this.selection.startOffset! >= index) {
179
- this.selection.setStart(slot, this.selection.startOffset! - action.delete)
298
+ if (slot === this.selection.anchorSlot && this.selection.anchorOffset! >= index) {
299
+ this.selection.setAnchor(slot, this.selection.startOffset! - action.delete)
180
300
  }
181
- if (slot === this.selection.endSlot && this.selection.endOffset! >= index) {
182
- this.selection.setEnd(slot, this.selection.endOffset! - action.delete)
301
+ if (slot === this.selection.focusSlot && this.selection.focusOffset! >= index) {
302
+ this.selection.setFocus(slot, this.selection.focusOffset! - action.delete)
183
303
  }
184
304
  }
185
305
  } else if (action.attributes) {
@@ -219,16 +339,14 @@ export class Collaborate implements History {
219
339
  const isEmpty = delta.length === 1 && delta[0].insert === Slot.emptyPlaceholder
220
340
  if (typeof action.content === 'string') {
221
341
  length = action.content.length
222
- content.insert(offset, action.content)
342
+ content.insert(offset, action.content, action.formats || {})
223
343
  } else {
224
344
  length = 1
225
345
  const component = slot.getContentAtIndex(offset) as ComponentInstance
226
346
  const sharedComponent = this.createSharedComponentByComponent(component)
227
347
  content.insertEmbed(offset, sharedComponent)
228
348
  }
229
- if (action.formats) {
230
- content.format(offset, length, action.formats)
231
- }
349
+
232
350
  if (isEmpty && offset === 0) {
233
351
  content.delete(content.length - 1, 1)
234
352
  }
@@ -243,6 +361,12 @@ export class Collaborate implements History {
243
361
  }
244
362
  })
245
363
  })
364
+
365
+ sub.add(slot.onChildComponentRemove.subscribe(components => {
366
+ components.forEach(c => {
367
+ this.cleanSubscriptionsByComponent(c)
368
+ })
369
+ }))
246
370
  this.contentSyncCaches.set(slot, () => {
247
371
  content.unobserve(syncRemote)
248
372
  sub.unsubscribe()
@@ -281,18 +405,21 @@ export class Collaborate implements History {
281
405
  const slots = component.slots
282
406
  const syncRemote = (ev, tr) => {
283
407
  this.runRemoteUpdate(tr, () => {
408
+ let index = 0
284
409
  ev.delta.forEach(action => {
285
410
  if (Reflect.has(action, 'retain')) {
286
- slots.retain(action.retain!)
411
+ index += action.retain
412
+ slots.retain(index)
287
413
  } else if (action.insert) {
288
414
  (action.insert as Array<YMap<any>>).forEach(item => {
289
415
  const slot = this.createSlotBySharedSlot(item)
290
416
  slots.insert(slot)
291
417
  this.syncContent(item.get('content'), slot)
292
418
  this.syncSlot(item, slot)
419
+ index++
293
420
  })
294
421
  } else if (action.delete) {
295
- slots.retain(slots.index)
422
+ slots.retain(index)
296
423
  slots.delete(action.delete)
297
424
  }
298
425
  })
@@ -313,15 +440,18 @@ export class Collaborate implements History {
313
440
  remoteSlots.insert(index, [sharedSlot])
314
441
  index++
315
442
  } else if (action.type === 'delete') {
316
- slots.slice(index, index + action.count).forEach(slot => {
317
- this.cleanSubscriptionsBySlot(slot)
318
- })
319
443
  remoteSlots.delete(index, action.count)
320
444
  }
321
445
  })
322
446
  })
323
447
  })
324
448
 
449
+ sub.add(slots.onChildSlotRemove.subscribe(slots => {
450
+ slots.forEach(slot => {
451
+ this.cleanSubscriptionsBySlot(slot)
452
+ })
453
+ }))
454
+
325
455
  this.slotsSyncCaches.set(component, () => {
326
456
  remoteSlots.unobserve(syncRemote)
327
457
  sub.unsubscribe()
@@ -355,7 +485,7 @@ export class Collaborate implements History {
355
485
  }
356
486
 
357
487
  private runLocalUpdate(fn: () => void) {
358
- if (this.updateFromRemote) {
488
+ if (this.updateFromRemote || this.controller.readonly) {
359
489
  return
360
490
  }
361
491
  this.updateRemoteActions.push(fn)
@@ -366,7 +496,11 @@ export class Collaborate implements History {
366
496
  return
367
497
  }
368
498
  this.updateFromRemote = true
369
- fn()
499
+ if (tr.origin === this.manager) {
500
+ this.scheduler.historyApplyTransact(fn)
501
+ } else {
502
+ this.scheduler.remoteUpdateTransact(fn)
503
+ }
370
504
  this.updateFromRemote = false
371
505
  }
372
506
 
@@ -414,7 +548,7 @@ export class Collaborate implements History {
414
548
  return sharedSlot
415
549
  }
416
550
 
417
- private createComponentBySharedComponent(yMap: YMap<any>): ComponentInstance {
551
+ private createComponentBySharedComponent(yMap: YMap<any>, canInsertInlineComponent: boolean): ComponentInstance {
418
552
  const sharedSlots = yMap.get('slots') as YArray<YMap<any>>
419
553
  const slots: Slot[] = []
420
554
  sharedSlots.forEach(sharedSlot => {
@@ -428,13 +562,17 @@ export class Collaborate implements History {
428
562
  })
429
563
  if (instance) {
430
564
  instance.slots.toArray().forEach((slot, index) => {
431
- const sharedSlot = sharedSlots.get(index)
565
+ let sharedSlot = sharedSlots.get(index)
566
+ if (!sharedSlot) {
567
+ sharedSlot = this.createSharedSlotBySlot(slot)
568
+ sharedSlots.push([sharedSlot])
569
+ }
432
570
  this.syncSlot(sharedSlot, slot)
433
571
  this.syncContent(sharedSlot.get('content'), slot)
434
572
  })
435
573
  return instance
436
574
  }
437
- throw collaborateErrorFn(`cannot find component factory \`${name}\`.`)
575
+ return createUnknownComponent(name, canInsertInlineComponent).createInstance(this.starter)
438
576
  }
439
577
 
440
578
  private createSlotBySharedSlot(sharedSlot: YMap<any>): Slot {
@@ -454,7 +592,8 @@ export class Collaborate implements History {
454
592
  slot.insert(action.insert, makeFormats(this.registry, action.attributes))
455
593
  } else {
456
594
  const sharedComponent = action.insert as YMap<any>
457
- const component = this.createComponentBySharedComponent(sharedComponent)
595
+ const canInsertInlineComponent = slot.schema.includes(ContentType.InlineComponent)
596
+ const component = this.createComponentBySharedComponent(sharedComponent, canInsertInlineComponent)
458
597
  slot.insert(component)
459
598
  this.syncSlots(sharedComponent.get('slots'), component)
460
599
  this.syncComponent(sharedComponent, component)
@@ -467,6 +606,7 @@ export class Collaborate implements History {
467
606
  }
468
607
 
469
608
  private cleanSubscriptionsBySlot(slot: Slot) {
609
+ this.contentMap.delete(slot);
470
610
  [this.contentSyncCaches.get(slot), this.slotStateSyncCaches.get(slot)].forEach(fn => {
471
611
  if (fn) {
472
612
  fn()
@@ -494,7 +634,7 @@ export class Collaborate implements History {
494
634
  function makeFormats(registry: Registry, attrs?: any) {
495
635
  const formats: Formats = []
496
636
  if (attrs) {
497
- Object.keys(attrs).map(key => {
637
+ Object.keys(attrs).forEach(key => {
498
638
  const formatter = registry.getFormatter(key)
499
639
  if (formatter) {
500
640
  formats.push([formatter, attrs[key]])
@@ -0,0 +1,35 @@
1
+ import { Plugin, Renderer, Scheduler } from '@textbus/core'
2
+ import { Injector } from '@tanbo/di'
3
+ import { Caret, CaretPosition } from '@textbus/browser'
4
+ import { Subscription } from '@tanbo/stream'
5
+
6
+ export class FixedCaretPlugin implements Plugin {
7
+ private subscriptions = new Subscription()
8
+
9
+ constructor(public scrollContainer: HTMLElement) {
10
+ }
11
+
12
+ setup(injector: Injector) {
13
+ const scheduler = injector.get(Scheduler)
14
+ const caret = injector.get(Caret)
15
+ const renderer = injector.get(Renderer)
16
+
17
+ let isChanged = false
18
+ let caretPosition: CaretPosition | null = null
19
+ renderer.onViewChecked.subscribe(() => {
20
+ isChanged = true
21
+ })
22
+ this.subscriptions.add(caret.onPositionChange.subscribe(position => {
23
+ if (isChanged && caretPosition && position && !scheduler.hasLocalUpdate) {
24
+ const offset = position.top - caretPosition.top
25
+ this.scrollContainer.scrollTop += offset
26
+ isChanged = false
27
+ }
28
+ caretPosition = position
29
+ }))
30
+ }
31
+
32
+ onDestroy() {
33
+ this.subscriptions.unsubscribe()
34
+ }
35
+ }
package/src/public-api.ts CHANGED
@@ -1,2 +1,19 @@
1
- export * from './collab/_api'
1
+ import { History, Module } from '@textbus/core'
2
+
3
+ import { Collaborate } from './collaborate'
4
+ import { CollaborateCursor } from './collaborate-cursor'
5
+
2
6
  export * from './collaborate'
7
+ export * from './collaborate-cursor'
8
+ export * from './fixed-caret.plugin'
9
+
10
+ export const collaborateModule: Module = {
11
+ providers: [
12
+ Collaborate,
13
+ CollaborateCursor,
14
+ {
15
+ provide: History,
16
+ useClass: Collaborate
17
+ }
18
+ ]
19
+ }
@@ -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 +0,0 @@
1
- export * from './collaborate-cursor';
@@ -1,2 +0,0 @@
1
- export * from './collaborate-cursor';
2
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiX2FwaS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy9jb2xsYWIvX2FwaS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxjQUFjLHNCQUFzQixDQUFBIiwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0ICogZnJvbSAnLi9jb2xsYWJvcmF0ZS1jdXJzb3InXG4iXX0=