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

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,23 +339,23 @@ 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
  }
235
353
  offset += length
236
354
  } else if (action.type === 'delete') {
237
355
  const delta = content.toDelta()
238
- content.delete(offset, action.count)
356
+ if (content.length) {
357
+ content.delete(offset, action.count)
358
+ }
239
359
  if (content.length === 0) {
240
360
  content.insert(0, '\n', delta[0]?.attributes)
241
361
  }
@@ -243,6 +363,12 @@ export class Collaborate implements History {
243
363
  }
244
364
  })
245
365
  })
366
+
367
+ sub.add(slot.onChildComponentRemove.subscribe(components => {
368
+ components.forEach(c => {
369
+ this.cleanSubscriptionsByComponent(c)
370
+ })
371
+ }))
246
372
  this.contentSyncCaches.set(slot, () => {
247
373
  content.unobserve(syncRemote)
248
374
  sub.unsubscribe()
@@ -281,18 +407,21 @@ export class Collaborate implements History {
281
407
  const slots = component.slots
282
408
  const syncRemote = (ev, tr) => {
283
409
  this.runRemoteUpdate(tr, () => {
410
+ let index = 0
284
411
  ev.delta.forEach(action => {
285
412
  if (Reflect.has(action, 'retain')) {
286
- slots.retain(action.retain!)
413
+ index += action.retain
414
+ slots.retain(index)
287
415
  } else if (action.insert) {
288
416
  (action.insert as Array<YMap<any>>).forEach(item => {
289
417
  const slot = this.createSlotBySharedSlot(item)
290
418
  slots.insert(slot)
291
419
  this.syncContent(item.get('content'), slot)
292
420
  this.syncSlot(item, slot)
421
+ index++
293
422
  })
294
423
  } else if (action.delete) {
295
- slots.retain(slots.index)
424
+ slots.retain(index)
296
425
  slots.delete(action.delete)
297
426
  }
298
427
  })
@@ -313,15 +442,18 @@ export class Collaborate implements History {
313
442
  remoteSlots.insert(index, [sharedSlot])
314
443
  index++
315
444
  } else if (action.type === 'delete') {
316
- slots.slice(index, index + action.count).forEach(slot => {
317
- this.cleanSubscriptionsBySlot(slot)
318
- })
319
445
  remoteSlots.delete(index, action.count)
320
446
  }
321
447
  })
322
448
  })
323
449
  })
324
450
 
451
+ sub.add(slots.onChildSlotRemove.subscribe(slots => {
452
+ slots.forEach(slot => {
453
+ this.cleanSubscriptionsBySlot(slot)
454
+ })
455
+ }))
456
+
325
457
  this.slotsSyncCaches.set(component, () => {
326
458
  remoteSlots.unobserve(syncRemote)
327
459
  sub.unsubscribe()
@@ -355,7 +487,7 @@ export class Collaborate implements History {
355
487
  }
356
488
 
357
489
  private runLocalUpdate(fn: () => void) {
358
- if (this.updateFromRemote) {
490
+ if (this.updateFromRemote || this.controller.readonly) {
359
491
  return
360
492
  }
361
493
  this.updateRemoteActions.push(fn)
@@ -366,7 +498,11 @@ export class Collaborate implements History {
366
498
  return
367
499
  }
368
500
  this.updateFromRemote = true
369
- fn()
501
+ if (tr.origin === this.manager) {
502
+ this.scheduler.historyApplyTransact(fn)
503
+ } else {
504
+ this.scheduler.remoteUpdateTransact(fn)
505
+ }
370
506
  this.updateFromRemote = false
371
507
  }
372
508
 
@@ -414,7 +550,7 @@ export class Collaborate implements History {
414
550
  return sharedSlot
415
551
  }
416
552
 
417
- private createComponentBySharedComponent(yMap: YMap<any>): ComponentInstance {
553
+ private createComponentBySharedComponent(yMap: YMap<any>, canInsertInlineComponent: boolean): ComponentInstance {
418
554
  const sharedSlots = yMap.get('slots') as YArray<YMap<any>>
419
555
  const slots: Slot[] = []
420
556
  sharedSlots.forEach(sharedSlot => {
@@ -428,13 +564,17 @@ export class Collaborate implements History {
428
564
  })
429
565
  if (instance) {
430
566
  instance.slots.toArray().forEach((slot, index) => {
431
- const sharedSlot = sharedSlots.get(index)
567
+ let sharedSlot = sharedSlots.get(index)
568
+ if (!sharedSlot) {
569
+ sharedSlot = this.createSharedSlotBySlot(slot)
570
+ sharedSlots.push([sharedSlot])
571
+ }
432
572
  this.syncSlot(sharedSlot, slot)
433
573
  this.syncContent(sharedSlot.get('content'), slot)
434
574
  })
435
575
  return instance
436
576
  }
437
- throw collaborateErrorFn(`cannot find component factory \`${name}\`.`)
577
+ return createUnknownComponent(name, canInsertInlineComponent).createInstance(this.starter)
438
578
  }
439
579
 
440
580
  private createSlotBySharedSlot(sharedSlot: YMap<any>): Slot {
@@ -454,7 +594,8 @@ export class Collaborate implements History {
454
594
  slot.insert(action.insert, makeFormats(this.registry, action.attributes))
455
595
  } else {
456
596
  const sharedComponent = action.insert as YMap<any>
457
- const component = this.createComponentBySharedComponent(sharedComponent)
597
+ const canInsertInlineComponent = slot.schema.includes(ContentType.InlineComponent)
598
+ const component = this.createComponentBySharedComponent(sharedComponent, canInsertInlineComponent)
458
599
  slot.insert(component)
459
600
  this.syncSlots(sharedComponent.get('slots'), component)
460
601
  this.syncComponent(sharedComponent, component)
@@ -467,6 +608,7 @@ export class Collaborate implements History {
467
608
  }
468
609
 
469
610
  private cleanSubscriptionsBySlot(slot: Slot) {
611
+ this.contentMap.delete(slot);
470
612
  [this.contentSyncCaches.get(slot), this.slotStateSyncCaches.get(slot)].forEach(fn => {
471
613
  if (fn) {
472
614
  fn()
@@ -494,7 +636,7 @@ export class Collaborate implements History {
494
636
  function makeFormats(registry: Registry, attrs?: any) {
495
637
  const formats: Formats = []
496
638
  if (attrs) {
497
- Object.keys(attrs).map(key => {
639
+ Object.keys(attrs).forEach(key => {
498
640
  const formatter = registry.getFormatter(key)
499
641
  if (formatter) {
500
642
  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,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,
@@ -1 +0,0 @@
1
- export * from './collaborate-cursor';
@@ -1,2 +0,0 @@
1
- export * from './collaborate-cursor';
2
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiX2FwaS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy9jb2xsYWIvX2FwaS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxjQUFjLHNCQUFzQixDQUFBIiwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0ICogZnJvbSAnLi9jb2xsYWJvcmF0ZS1jdXJzb3InXG4iXX0=