From d79b24e6b7f24da597c4bbff15664fdfc3674fef Mon Sep 17 00:00:00 2001 From: situ2001 Date: Sat, 6 Aug 2022 19:16:48 +0800 Subject: [PATCH 1/4] feat(collaboration): init user info, cursor widget --- .../src/browser/collaboration.contribution.ts | 23 ++- .../src/browser/collaboration.service.ts | 45 ++++- .../src/browser/cursor-widget.ts | 176 ++++++++++++++++++ packages/collaboration/src/browser/index.ts | 12 +- .../src/browser/textmodel-binding.ts | 39 +++- packages/collaboration/src/common/index.ts | 15 ++ 6 files changed, 301 insertions(+), 9 deletions(-) create mode 100644 packages/collaboration/src/browser/cursor-widget.ts diff --git a/packages/collaboration/src/browser/collaboration.contribution.ts b/packages/collaboration/src/browser/collaboration.contribution.ts index 53c74db01d..58daddfaca 100644 --- a/packages/collaboration/src/browser/collaboration.contribution.ts +++ b/packages/collaboration/src/browser/collaboration.contribution.ts @@ -6,11 +6,20 @@ import { KeybindingWeight, PreferenceService, } from '@opensumi/ide-core-browser'; -import { CommandContribution, CommandRegistry, Domain } from '@opensumi/ide-core-common'; +import { CommandContribution, CommandRegistry, ContributionProvider, Domain, uuid } from '@opensumi/ide-core-common'; -import { ICollaborationService } from '../common'; +import { ICollaborationService, UserInfo, UserInfoForCollaborationContribution } from '../common'; import { REDO, UNDO } from '../common/commands'; +// mock user info +@Domain(UserInfoForCollaborationContribution) +export class MyUserInfo implements UserInfoForCollaborationContribution { + info: UserInfo = { + id: uuid().slice(0, 4), + nickname: `${uuid().slice(0, 4)}`, + }; +} + @Domain(ClientAppContribution, KeybindingContribution, CommandContribution) export class CollaborationContribution implements ClientAppContribution, KeybindingContribution, CommandContribution { @Autowired(ICollaborationService) @@ -19,10 +28,20 @@ export class CollaborationContribution implements ClientAppContribution, Keybind @Autowired(PreferenceService) private preferenceService: PreferenceService; + @Autowired(UserInfoForCollaborationContribution) + private readonly userInfoProvider: ContributionProvider; + onDidStart() { if (this.preferenceService.get('editor.askIfDiff') === true) { this.preferenceService.set('editor.askIfDiff', false); } + + // before init + const providers = this.userInfoProvider.getContributions(); + for (const provider of providers) { + this.collaborationService.setUserInfo(provider); + } + this.collaborationService.initialize(); } diff --git a/packages/collaboration/src/browser/collaboration.service.ts b/packages/collaboration/src/browser/collaboration.service.ts index d2231ec29e..de3ee438f7 100644 --- a/packages/collaboration/src/browser/collaboration.service.ts +++ b/packages/collaboration/src/browser/collaboration.service.ts @@ -5,7 +5,11 @@ import * as Y from 'yjs'; import { Injectable, Autowired, Inject, INJECTOR_TOKEN, Injector } from '@opensumi/di'; import { ILogger, OnEvent, WithEventBus } from '@opensumi/ide-core-common'; import { WorkbenchEditorService } from '@opensumi/ide-editor'; -import { EditorActiveResourceStateChangedEvent, EditorGroupCloseEvent } from '@opensumi/ide-editor/lib/browser'; +import { + EditorActiveResourceStateChangedEvent, + EditorDocumentModelCreationEvent, + EditorGroupCloseEvent, +} from '@opensumi/ide-editor/lib/browser'; import { WorkbenchEditorServiceImpl } from '@opensumi/ide-editor/lib/browser/workbench-editor.service'; import { ITextModel, ICodeEditor } from '@opensumi/ide-monaco'; @@ -14,8 +18,11 @@ import { ICollaborationService, ICollaborationServiceForClient, ROOM_NAME, + UserInfo, + UserInfoForCollaborationContribution, } from '../common'; +import { CursorWidgetRegistry } from './cursor-widget'; import { TextModelBinding } from './textmodel-binding'; import './styles.less'; @@ -36,6 +43,11 @@ export class CollaborationService extends WithEventBus implements ICollaboration @Autowired(WorkbenchEditorService) private workbenchEditorService: WorkbenchEditorServiceImpl; + // hold editor => registry + private cursorRegistryMap: Map = new Map(); + + private userInfo: UserInfo; + private yDoc: Y.Doc; private yWebSocketProvider: WebsocketProvider; @@ -76,6 +88,10 @@ export class CollaborationService extends WithEventBus implements ICollaboration this.yTextMap = this.yDoc.getMap(); this.yWebSocketProvider = new WebsocketProvider('ws://127.0.0.1:12345', ROOM_NAME, this.yDoc); // TODO configurable uri and room name this.yTextMap.observe(this.yMapObserver); + + // TODO add userInfo to awareness field + this.yWebSocketProvider.awareness.setLocalStateField('user-info', this.userInfo); + this.logger.debug('Collaboration initialized'); } @@ -85,6 +101,24 @@ export class CollaborationService extends WithEventBus implements ICollaboration this.bindingMap.forEach((binding) => binding.dispose()); } + getUseInfo(): UserInfo { + if (!this.userInfo) { + throw new Error('User info is not registered'); + } + + return this.userInfo; + } + + setUserInfo(contribution: UserInfoForCollaborationContribution) { + if (this.userInfo) { + throw new Error('User info is already registered'); + } + + if (contribution.info) { + this.userInfo = contribution.info; + } + } + undoOnCurrentResource() { const uri = this.workbenchEditorService.currentResource?.uri.toString(); if (uri && this.bindingMap.has(uri)) { @@ -136,6 +170,7 @@ export class CollaborationService extends WithEventBus implements ICollaboration } } + // TODO TextModel的创建可以监听EditorDocumentModelCreationEvent 由于Binding和TextModel是一一对应的 这样做会容易处理得多 @OnEvent(EditorGroupCloseEvent) private groupCloseHandler(e: EditorGroupCloseEvent) { this.logger.debug('Group close tabs', e); @@ -167,6 +202,14 @@ export class CollaborationService extends WithEventBus implements ICollaboration const monacoEditor = this.workbenchEditorService.currentCodeEditor?.monacoEditor; const binding = this.getBinding(uri); + // check if editor has its widgetRegistry + if (monacoEditor && !this.cursorRegistryMap.has(monacoEditor)) { + this.cursorRegistryMap.set( + monacoEditor, + this.injector.get(CursorWidgetRegistry, [monacoEditor, this.yWebSocketProvider.awareness]), + ); + } + // check if there exists any binding if (!binding) { if (this.yTextMap.has(uri)) { diff --git a/packages/collaboration/src/browser/cursor-widget.ts b/packages/collaboration/src/browser/cursor-widget.ts new file mode 100644 index 0000000000..b43d922cfa --- /dev/null +++ b/packages/collaboration/src/browser/cursor-widget.ts @@ -0,0 +1,176 @@ +import { Awareness } from 'y-protocols/awareness'; + +import { Autowired, Injectable, Injector, INJECTOR_TOKEN } from '@opensumi/di'; +import { IDisposable } from '@opensumi/ide-core-common'; +import { ICodeEditor } from '@opensumi/ide-monaco'; +import { monaco } from '@opensumi/ide-monaco/lib/browser/monaco-api'; +import { + IContentWidget, + IContentWidgetPosition, +} from '@opensumi/monaco-editor-core/esm/vs/editor/browser/editorBrowser'; + +import { UserInfo } from '../common'; + +export interface ICursorWidgetRegistry { + /** + * update specified position of widget, but not invoke `layoutWidget` + * @param id + * @param pos + */ + updatePositionOf(id: string, lineNumber: number, column: number): void; + /** + * set all position of widget to null + * @param editor + */ + removeAllPositions(editor: ICodeEditor): void; + /** + * update all position of widget, `layoutWidget` is invoked + */ + layoutAllWidgets(): void; + /** + * destroy this registry and all its widgets + */ + destroy(): void; +} + +const createPositionFrom = (lineNumber: number, column: number): IContentWidgetPosition => ({ + position: { lineNumber, column }, + preference: [ + monaco.editor.ContentWidgetPositionPreference.ABOVE, + monaco.editor.ContentWidgetPositionPreference.BELOW, + ], +}); + +/** + * one editor holds one CursorWidgetRegistry + */ +@Injectable({ multiple: true }) +export class CursorWidgetRegistry implements ICursorWidgetRegistry { + @Autowired(INJECTOR_TOKEN) + private readonly injector: Injector; + + /** + * store all widgets here, and widgets will be automatically added or removed from this registry + * + * nickname => widget + */ + widgets: Map = new Map(); + + // target editor + editor: ICodeEditor; + + awareness: Awareness; + + disposable: IDisposable; + + constructor(editor: ICodeEditor, awareness: Awareness) { + this.editor = editor; + this.awareness = awareness; + this.disposable = editor.onDidDispose(() => this.destroy()); + awareness.on('change', this.onAwarenessStateChange); + + this.getWidgetFromRegistry(); + } + + private getWidgetFromRegistry() { + // create widget from awareness + this.awareness.getStates().forEach((state) => { + const info: UserInfo = state['user-info']; + if (info) { + this.createWidget(info.nickname); + } + }); + } + + updatePositionOf(nickname: string, lineNumber: number, column: number) { + const widget = this.widgets.get(nickname); + if (widget) { + widget.position = createPositionFrom(lineNumber, column); + } + } + + removeAllPositions() { + this.widgets.forEach((widget) => { + widget.position = null; + }); + } + + layoutAllWidgets() { + this.widgets.forEach((widget) => { + this.editor.layoutContentWidget(widget); + }); + } + + destroy() { + // remove all from editor + this.widgets.forEach((widget) => { + this.editor.removeContentWidget(widget); + }); + this.awareness.off('change', this.onAwarenessStateChange); + if (this.disposable) { + this.disposable.dispose(); + } + } + + private createWidget(nickname: string) { + if (!this.widgets.has(nickname)) { + const widget = this.injector.get(CursorWidget, [nickname]); + this.editor.addContentWidget(widget); + this.widgets.set(nickname, widget); + } + } + + private deleteWidget(nickname: string) { + const widget = this.widgets.get(nickname); + if (widget) { + this.editor.removeContentWidget(widget); + this.widgets.delete(nickname); + } + } + + private onAwarenessStateChange = (changes: { added: number[]; updated: number[]; removed: number[] }) => { + this.getWidgetFromRegistry(); + + // TODO when was removed + if (changes.added.length > 0) { + } + if (changes.removed.length > 0) { + } + }; +} + +// TODO get color by nick name +const getColorByNickName = (nickname: string): string => 'pink'; + +@Injectable({ multiple: true }) +export class CursorWidget implements IContentWidget { + // domNode + private domNode: HTMLElement; + + private id: string; + + position: IContentWidgetPosition | null = null; + + constructor(nickname: string) { + // init dom node + this.domNode = document.createElement('div'); + this.domNode.innerHTML = nickname; + this.domNode.style.color = 'white'; + this.domNode.style.background = getColorByNickName(nickname); + this.domNode.style.fontSize = '0.8rem'; + // set id + this.id = `cursor-widget-${nickname}`; + } + + getId(): string { + return this.id; + } + + getDomNode(): HTMLElement { + return this.domNode; + } + + getPosition(): IContentWidgetPosition | null { + return this.position; + } +} diff --git a/packages/collaboration/src/browser/index.ts b/packages/collaboration/src/browser/index.ts index 35f379f933..ad4fa0fe14 100644 --- a/packages/collaboration/src/browser/index.ts +++ b/packages/collaboration/src/browser/index.ts @@ -1,15 +1,21 @@ -import { Provider, Injectable } from '@opensumi/di'; +import { Provider, Injectable, Domain } from '@opensumi/di'; import { BrowserModule } from '@opensumi/ide-core-browser'; -import { ICollaborationService, CollaborationServiceForClientPath } from '../common'; +import { + ICollaborationService, + CollaborationServiceForClientPath, + UserInfoForCollaborationContribution, +} from '../common'; -import { CollaborationContribution } from './collaboration.contribution'; +import { CollaborationContribution, MyUserInfo } from './collaboration.contribution'; import { CollaborationService } from './collaboration.service'; @Injectable() export class CollaborationModule extends BrowserModule { + contributionProvider: Domain | Domain[] = [UserInfoForCollaborationContribution]; providers: Provider[] = [ CollaborationContribution, + MyUserInfo, // TODO debug, will be removed { token: ICollaborationService, useClass: CollaborationService, diff --git a/packages/collaboration/src/browser/textmodel-binding.ts b/packages/collaboration/src/browser/textmodel-binding.ts index 6337ee5c7a..72fb8d0b2a 100644 --- a/packages/collaboration/src/browser/textmodel-binding.ts +++ b/packages/collaboration/src/browser/textmodel-binding.ts @@ -2,8 +2,9 @@ import { createMutex } from 'lib0/mutex'; import { Awareness } from 'y-protocols/awareness'; import * as Y from 'yjs'; -import { Injectable } from '@opensumi/di'; +import { Injectable, Autowired } from '@opensumi/di'; import { ITextModel, ICodeEditor, Position } from '@opensumi/ide-monaco'; +import { IModelDeltaDecoration } from '@opensumi/ide-monaco/lib/browser/monaco-api/editor'; import { editor, SelectionDirection, @@ -12,8 +13,16 @@ import { IDisposable, } from '@opensumi/monaco-editor-core/esm/vs/editor/editor.api'; +import { ICollaborationService, UserInfo } from '../common'; + +import { CollaborationService } from './collaboration.service'; +import { CursorWidgetRegistry } from './cursor-widget'; + @Injectable({ multiple: true }) export class TextModelBinding { + @Autowired(ICollaborationService) + private collaborationService: CollaborationService; + savedSelections: Map = new Map(); mutex = createMutex(); @@ -52,7 +61,14 @@ export class TextModelBinding { this.editors.forEach((editor) => { if (editor.getModel() === this.textModel) { const currentDecorations = this.decorations.get(editor) ?? []; - const newDecorations: any[] = []; // fixme + const newDecorations: IModelDeltaDecoration[] = []; // re-populate decorations + + // FIXME it is just a test, will call method from collaboration service + const cursorWidgetRegistry: CursorWidgetRegistry = this.collaborationService['cursorRegistryMap'].get(editor)!; + + // set position of CursorWidget to null + cursorWidgetRegistry.removeAllPositions(); + this.awareness.getStates().forEach((state, clientID) => { // if clientID is not mine, and selection from this client is not empty if ( @@ -63,9 +79,11 @@ export class TextModelBinding { ) { const anchorAbs = Y.createAbsolutePositionFromRelativePosition(state.selection.anchor, this.doc); const headAbs = Y.createAbsolutePositionFromRelativePosition(state.selection.head, this.doc); + if ( anchorAbs !== null && headAbs !== null && + // ensure that the client is in the same Y.Text with mine anchorAbs.type === this.yText && headAbs.type === this.yText ) { @@ -90,19 +108,30 @@ export class TextModelBinding { newDecorations.push({ range: new Range(start.lineNumber, start.column, end.lineNumber, end.column), options: { + description: 'yjs decoration ' + clientID, className: 'yRemoteSelection yRemoteSelection-' + clientID, afterContentClassName, beforeContentClassName, }, }); + + // update position + const { nickname }: UserInfo = state['user-info']; + cursorWidgetRegistry.updatePositionOf(nickname, end.lineNumber, end.column); } } }); + // invoke layoutWidget method to update update all cursor widgets + cursorWidgetRegistry.layoutAllWidgets(); + + // delta update decorations this.decorations.set(editor, editor.deltaDecorations(currentDecorations, newDecorations)); } else { - // ignore decoration + // remove all decoration, when current active TextModel of this editor is not this.textModel this.decorations.delete(editor); + // TODO may need to remove all widgets + // widgets.remove() } }); }; @@ -272,7 +301,11 @@ export class TextModelBinding { this.doc.off('beforeAllTransactions', this.beforeAllTransactionsHandler); this.yText.unobserve(this.yTextObserver); this.awareness.off('change', this.renderDecorations); + + // destroy all widgets, no no no, should manage widget in collab service } + + // TODO when active resource changed, re-render decoration? } class RelativeSelection { diff --git a/packages/collaboration/src/common/index.ts b/packages/collaboration/src/common/index.ts index dae0a6e055..5eae20b64f 100644 --- a/packages/collaboration/src/common/index.ts +++ b/packages/collaboration/src/common/index.ts @@ -7,6 +7,8 @@ export interface ICollaborationService { destroy(): void; undoOnCurrentResource(): void; redoOnCurrentResource(): void; + getUseInfo(): UserInfo; + setUserInfo(contribution: UserInfoForCollaborationContribution): void; } export const IYWebsocketServer = Symbol('IYWebsocketServer'); @@ -25,3 +27,16 @@ export interface ICollaborationServiceForClient { } export const ROOM_NAME = 'y-room-opensumi'; + +// user model for collaboration module +export const UserInfoForCollaborationContribution = Symbol('UserInfoForCollaborationContribution'); + +export interface UserInfoForCollaborationContribution { + info: UserInfo; +} + +export interface UserInfo { + id: string; // unique id + nickname: string; // will be displayed on live cursor + // may be more data fields +} From a523a5247159bf546fd11878f149d15609e28479 Mon Sep 17 00:00:00 2001 From: situ2001 Date: Fri, 12 Aug 2022 18:11:46 +0800 Subject: [PATCH 2/4] feat: add user info and indicator --- packages/collaboration/package.json | 1 + .../src/browser/collaboration.service.ts | 79 ++++++++++++++++--- packages/collaboration/src/browser/color.ts | 4 + .../src/browser/cursor-widget.ts | 54 ++++++------- .../collaboration/src/browser/styles.less | 7 +- .../src/browser/textmodel-binding.ts | 10 +-- 6 files changed, 108 insertions(+), 47 deletions(-) create mode 100644 packages/collaboration/src/browser/color.ts diff --git a/packages/collaboration/package.json b/packages/collaboration/package.json index 0317681a00..bf28c784d3 100644 --- a/packages/collaboration/package.json +++ b/packages/collaboration/package.json @@ -28,6 +28,7 @@ "@opensumi/ide-core-browser": "^2.18.7", "@opensumi/ide-editor": "2.18.7", "@opensumi/ide-monaco": "2.18.7", + "@opensumi/ide-theme": "2.18.7", "@opensumi/ide-dev-tool": "^1.3.1", "y-protocols": "^1.0.2" } diff --git a/packages/collaboration/src/browser/collaboration.service.ts b/packages/collaboration/src/browser/collaboration.service.ts index de3ee438f7..e6848426ee 100644 --- a/packages/collaboration/src/browser/collaboration.service.ts +++ b/packages/collaboration/src/browser/collaboration.service.ts @@ -5,13 +5,10 @@ import * as Y from 'yjs'; import { Injectable, Autowired, Inject, INJECTOR_TOKEN, Injector } from '@opensumi/di'; import { ILogger, OnEvent, WithEventBus } from '@opensumi/ide-core-common'; import { WorkbenchEditorService } from '@opensumi/ide-editor'; -import { - EditorActiveResourceStateChangedEvent, - EditorDocumentModelCreationEvent, - EditorGroupCloseEvent, -} from '@opensumi/ide-editor/lib/browser'; +import { EditorActiveResourceStateChangedEvent, EditorGroupCloseEvent } from '@opensumi/ide-editor/lib/browser'; import { WorkbenchEditorServiceImpl } from '@opensumi/ide-editor/lib/browser/workbench-editor.service'; import { ITextModel, ICodeEditor } from '@opensumi/ide-monaco'; +import { ICSSStyleService } from '@opensumi/ide-theme'; import { CollaborationServiceForClientPath, @@ -22,6 +19,7 @@ import { UserInfoForCollaborationContribution, } from '../common'; +import { getColorByClientID } from './color'; import { CursorWidgetRegistry } from './cursor-widget'; import { TextModelBinding } from './textmodel-binding'; @@ -43,6 +41,11 @@ export class CollaborationService extends WithEventBus implements ICollaboration @Autowired(WorkbenchEditorService) private workbenchEditorService: WorkbenchEditorServiceImpl; + @Autowired(ICSSStyleService) + private cssManager: ICSSStyleService; + + private clientIDStyleAddedSet: Set = new Set(); + // hold editor => registry private cursorRegistryMap: Map = new Map(); @@ -89,13 +92,21 @@ export class CollaborationService extends WithEventBus implements ICollaboration this.yWebSocketProvider = new WebsocketProvider('ws://127.0.0.1:12345', ROOM_NAME, this.yDoc); // TODO configurable uri and room name this.yTextMap.observe(this.yMapObserver); - // TODO add userInfo to awareness field + // add userInfo to awareness field this.yWebSocketProvider.awareness.setLocalStateField('user-info', this.userInfo); this.logger.debug('Collaboration initialized'); + + this.yWebSocketProvider.awareness.on('update', this.updateCSSManagerWhenAwarenessUpdated); } destroy() { + this.yWebSocketProvider.awareness.off('update', this.updateCSSManagerWhenAwarenessUpdated); + this.clientIDStyleAddedSet.forEach((clientID) => { + this.cssManager.removeClass(`yRemoteSelection-${clientID}`); + this.cssManager.removeClass(`yRemoteSelectionHead-${clientID}`); + this.cssManager.removeClass(`yRemoteSelectionHead-${clientID}::after`); + }); this.yTextMap.unobserve(this.yMapObserver); this.yWebSocketProvider.disconnect(); this.bindingMap.forEach((binding) => binding.dispose()); @@ -170,7 +181,52 @@ export class CollaborationService extends WithEventBus implements ICollaboration } } - // TODO TextModel的创建可以监听EditorDocumentModelCreationEvent 由于Binding和TextModel是一一对应的 这样做会容易处理得多 + public getCursorWidgetRegistry(editor: ICodeEditor) { + return this.cursorRegistryMap.get(editor); + } + + private updateCSSManagerWhenAwarenessUpdated = (changes: { + added: number[]; + updated: number[]; + removed: number[]; + }) => { + if (changes.removed.length > 0) { + changes.removed.forEach((clientID) => { + this.cssManager.removeClass(`yRemoteSelection-${clientID}`); + this.cssManager.removeClass(`yRemoteSelectionHead-${clientID}`); + this.cssManager.removeClass(`yRemoteSelectionHead-${clientID}::after`); + this.clientIDStyleAddedSet.delete(clientID); + }); + } + if (changes.added.length > 0 || changes.updated.length > 0) { + changes.added.forEach((clientID) => { + if (!this.clientIDStyleAddedSet.has(clientID)) { + const color = getColorByClientID(clientID); + this.cssManager.addClass(`yRemoteSelection-${clientID}`, { + backgroundColor: color, + opacity: '0.25', + }); + this.cssManager.addClass(`yRemoteSelectionHead-${clientID}`, { + position: 'absolute', + borderLeft: `${color} solid 2px`, + borderBottom: `${color} solid 2px`, + borderTop: `${color} solid 2px`, + height: '100%', + boxSizing: 'border-box', + }); + this.cssManager.addClass(`yRemoteSelectionHead-${clientID}::after`, { + position: 'absolute', + content: ' ', + border: `3px solid ${color}`, + left: '-4px', + top: '-5px', + }); + this.clientIDStyleAddedSet.add(clientID); + } + }); + } + }; + @OnEvent(EditorGroupCloseEvent) private groupCloseHandler(e: EditorGroupCloseEvent) { this.logger.debug('Group close tabs', e); @@ -204,10 +260,11 @@ export class CollaborationService extends WithEventBus implements ICollaboration // check if editor has its widgetRegistry if (monacoEditor && !this.cursorRegistryMap.has(monacoEditor)) { - this.cursorRegistryMap.set( - monacoEditor, - this.injector.get(CursorWidgetRegistry, [monacoEditor, this.yWebSocketProvider.awareness]), - ); + const registry = this.injector.get(CursorWidgetRegistry, [monacoEditor, this.yWebSocketProvider.awareness]); + this.cursorRegistryMap.set(monacoEditor, registry); + monacoEditor.onDidDispose(() => { + registry.destroy(); + }); } // check if there exists any binding diff --git a/packages/collaboration/src/browser/color.ts b/packages/collaboration/src/browser/color.ts new file mode 100644 index 0000000000..48bd6f6436 --- /dev/null +++ b/packages/collaboration/src/browser/color.ts @@ -0,0 +1,4 @@ +const color = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violent']; + +// get color by clientID +export const getColorByClientID = (id: number): string => color[id % color.length]; diff --git a/packages/collaboration/src/browser/cursor-widget.ts b/packages/collaboration/src/browser/cursor-widget.ts index b43d922cfa..a9bcf4ab32 100644 --- a/packages/collaboration/src/browser/cursor-widget.ts +++ b/packages/collaboration/src/browser/cursor-widget.ts @@ -11,13 +11,15 @@ import { import { UserInfo } from '../common'; +import { getColorByClientID } from './color'; + export interface ICursorWidgetRegistry { /** * update specified position of widget, but not invoke `layoutWidget` * @param id * @param pos */ - updatePositionOf(id: string, lineNumber: number, column: number): void; + updatePositionOf(clientID: number, lineNumber: number, column: number): void; /** * set all position of widget to null * @param editor @@ -52,9 +54,9 @@ export class CursorWidgetRegistry implements ICursorWidgetRegistry { /** * store all widgets here, and widgets will be automatically added or removed from this registry * - * nickname => widget + * clientID => widget */ - widgets: Map = new Map(); + widgets: Map = new Map(); // target editor editor: ICodeEditor; @@ -67,23 +69,23 @@ export class CursorWidgetRegistry implements ICursorWidgetRegistry { this.editor = editor; this.awareness = awareness; this.disposable = editor.onDidDispose(() => this.destroy()); - awareness.on('change', this.onAwarenessStateChange); + awareness.on('update', this.onAwarenessStateChange); this.getWidgetFromRegistry(); } private getWidgetFromRegistry() { // create widget from awareness - this.awareness.getStates().forEach((state) => { + this.awareness.getStates().forEach((state, clientID) => { const info: UserInfo = state['user-info']; if (info) { - this.createWidget(info.nickname); + this.createWidget(clientID, info.nickname); } }); } - updatePositionOf(nickname: string, lineNumber: number, column: number) { - const widget = this.widgets.get(nickname); + updatePositionOf(clientID: number, lineNumber: number, column: number) { + const widget = this.widgets.get(clientID); if (widget) { widget.position = createPositionFrom(lineNumber, column); } @@ -106,58 +108,54 @@ export class CursorWidgetRegistry implements ICursorWidgetRegistry { this.widgets.forEach((widget) => { this.editor.removeContentWidget(widget); }); - this.awareness.off('change', this.onAwarenessStateChange); + this.awareness.off('update', this.onAwarenessStateChange); if (this.disposable) { this.disposable.dispose(); } } - private createWidget(nickname: string) { - if (!this.widgets.has(nickname)) { - const widget = this.injector.get(CursorWidget, [nickname]); + private createWidget(clientID: number, nickname: string) { + if (!this.widgets.has(clientID)) { + const widget = this.injector.get(CursorWidget, [nickname, clientID]); this.editor.addContentWidget(widget); - this.widgets.set(nickname, widget); + this.widgets.set(clientID, widget); } } - private deleteWidget(nickname: string) { - const widget = this.widgets.get(nickname); + private deleteWidget(clientID: number) { + const widget = this.widgets.get(clientID); if (widget) { this.editor.removeContentWidget(widget); - this.widgets.delete(nickname); + this.widgets.delete(clientID); } } private onAwarenessStateChange = (changes: { added: number[]; updated: number[]; removed: number[] }) => { - this.getWidgetFromRegistry(); - - // TODO when was removed - if (changes.added.length > 0) { + // clientID added, updated or removed + if (changes.added.length > 0 || changes.updated.length > 0) { + this.getWidgetFromRegistry(); } + if (changes.removed.length > 0) { + changes.removed.forEach((clientID) => this.deleteWidget(clientID)); } }; } -// TODO get color by nick name -const getColorByNickName = (nickname: string): string => 'pink'; - @Injectable({ multiple: true }) export class CursorWidget implements IContentWidget { - // domNode private domNode: HTMLElement; private id: string; position: IContentWidgetPosition | null = null; - constructor(nickname: string) { + constructor(nickname: string, clientID: string) { // init dom node this.domNode = document.createElement('div'); this.domNode.innerHTML = nickname; - this.domNode.style.color = 'white'; - this.domNode.style.background = getColorByNickName(nickname); - this.domNode.style.fontSize = '0.8rem'; + this.domNode.style.opacity = '1'; + this.domNode.className = `yRemoteSelection-${clientID}`; // set id this.id = `cursor-widget-${nickname}`; } diff --git a/packages/collaboration/src/browser/styles.less b/packages/collaboration/src/browser/styles.less index bc5009a18a..8b551a6b82 100644 --- a/packages/collaboration/src/browser/styles.less +++ b/packages/collaboration/src/browser/styles.less @@ -1,6 +1,9 @@ +// fallback style + .yRemoteSelection { - background-color: rgba(255, 192, 203, 0.2); + background-color: rgba(255, 192, 203, 0.25); } + .yRemoteSelectionHead { position: absolute; border-left: pink solid 2px; @@ -9,11 +12,11 @@ height: 100%; box-sizing: border-box; } + .yRemoteSelectionHead::after { position: absolute; content: ' '; border: 3px solid pink; - border-radius: 4px; left: -4px; top: -5px; } diff --git a/packages/collaboration/src/browser/textmodel-binding.ts b/packages/collaboration/src/browser/textmodel-binding.ts index fe87f0a631..c2d8c927c1 100644 --- a/packages/collaboration/src/browser/textmodel-binding.ts +++ b/packages/collaboration/src/browser/textmodel-binding.ts @@ -63,11 +63,10 @@ export class TextModelBinding { const currentDecorations = this.decorations.get(editor) ?? []; const newDecorations: IModelDeltaDecoration[] = []; // re-populate decorations - // FIXME it is just a test, will call method from collaboration service - const cursorWidgetRegistry: CursorWidgetRegistry = this.collaborationService['cursorRegistryMap'].get(editor)!; + const cursorWidgetRegistry = this.collaborationService.getCursorWidgetRegistry(editor); // set position of CursorWidget to null - cursorWidgetRegistry.removeAllPositions(); + cursorWidgetRegistry?.removeAllPositions(); this.awareness.getStates().forEach((state, clientID) => { // if clientID is not mine, and selection from this client is not empty @@ -116,14 +115,13 @@ export class TextModelBinding { }); // update position - const { nickname }: UserInfo = state['user-info']; - cursorWidgetRegistry.updatePositionOf(nickname, end.lineNumber, end.column); + cursorWidgetRegistry?.updatePositionOf(clientID, end.lineNumber, end.column); } } }); // invoke layoutWidget method to update update all cursor widgets - cursorWidgetRegistry.layoutAllWidgets(); + cursorWidgetRegistry?.layoutAllWidgets(); // delta update decorations this.decorations.set(editor, editor.deltaDecorations(currentDecorations, newDecorations)); From d039585f06087df6e3b17cf12f3676eee7942105 Mon Sep 17 00:00:00 2001 From: situ2001 Date: Fri, 12 Aug 2022 19:43:28 +0800 Subject: [PATCH 3/4] refactor: refactor constants and interface --- .../src/browser/collaboration.service.ts | 21 +++++++------- .../src/browser/cursor-widget.ts | 28 ++----------------- .../src/browser/textmodel-binding.ts | 8 +++--- packages/collaboration/src/common/index.ts | 25 +++++++++++++++++ 4 files changed, 42 insertions(+), 40 deletions(-) diff --git a/packages/collaboration/src/browser/collaboration.service.ts b/packages/collaboration/src/browser/collaboration.service.ts index e6848426ee..f7e2ef100c 100644 --- a/packages/collaboration/src/browser/collaboration.service.ts +++ b/packages/collaboration/src/browser/collaboration.service.ts @@ -17,6 +17,8 @@ import { ROOM_NAME, UserInfo, UserInfoForCollaborationContribution, + Y_REMOTE_SELECTION, + Y_REMOTE_SELECTION_HEAD, } from '../common'; import { getColorByClientID } from './color'; @@ -103,9 +105,9 @@ export class CollaborationService extends WithEventBus implements ICollaboration destroy() { this.yWebSocketProvider.awareness.off('update', this.updateCSSManagerWhenAwarenessUpdated); this.clientIDStyleAddedSet.forEach((clientID) => { - this.cssManager.removeClass(`yRemoteSelection-${clientID}`); - this.cssManager.removeClass(`yRemoteSelectionHead-${clientID}`); - this.cssManager.removeClass(`yRemoteSelectionHead-${clientID}::after`); + this.cssManager.removeClass(`${Y_REMOTE_SELECTION}-${clientID}`); + this.cssManager.removeClass(`${Y_REMOTE_SELECTION_HEAD}-${clientID}`); + this.cssManager.removeClass(`${Y_REMOTE_SELECTION_HEAD}-${clientID}::after`); }); this.yTextMap.unobserve(this.yMapObserver); this.yWebSocketProvider.disconnect(); @@ -176,7 +178,6 @@ export class CollaborationService extends WithEventBus implements ICollaboration if (binding) { binding.dispose(); this.bindingMap.delete(uri); - // todo ref = ref - 1 (through back service) this.logger.debug('Removed binding'); } } @@ -192,9 +193,9 @@ export class CollaborationService extends WithEventBus implements ICollaboration }) => { if (changes.removed.length > 0) { changes.removed.forEach((clientID) => { - this.cssManager.removeClass(`yRemoteSelection-${clientID}`); - this.cssManager.removeClass(`yRemoteSelectionHead-${clientID}`); - this.cssManager.removeClass(`yRemoteSelectionHead-${clientID}::after`); + this.cssManager.removeClass(`${Y_REMOTE_SELECTION}-${clientID}`); + this.cssManager.removeClass(`${Y_REMOTE_SELECTION_HEAD}-${clientID}`); + this.cssManager.removeClass(`${Y_REMOTE_SELECTION_HEAD}-${clientID}::after`); this.clientIDStyleAddedSet.delete(clientID); }); } @@ -202,11 +203,11 @@ export class CollaborationService extends WithEventBus implements ICollaboration changes.added.forEach((clientID) => { if (!this.clientIDStyleAddedSet.has(clientID)) { const color = getColorByClientID(clientID); - this.cssManager.addClass(`yRemoteSelection-${clientID}`, { + this.cssManager.addClass(`${Y_REMOTE_SELECTION}-${clientID}`, { backgroundColor: color, opacity: '0.25', }); - this.cssManager.addClass(`yRemoteSelectionHead-${clientID}`, { + this.cssManager.addClass(`${Y_REMOTE_SELECTION_HEAD}-${clientID}`, { position: 'absolute', borderLeft: `${color} solid 2px`, borderBottom: `${color} solid 2px`, @@ -214,7 +215,7 @@ export class CollaborationService extends WithEventBus implements ICollaboration height: '100%', boxSizing: 'border-box', }); - this.cssManager.addClass(`yRemoteSelectionHead-${clientID}::after`, { + this.cssManager.addClass(`${Y_REMOTE_SELECTION_HEAD}-${clientID}::after`, { position: 'absolute', content: ' ', border: `3px solid ${color}`, diff --git a/packages/collaboration/src/browser/cursor-widget.ts b/packages/collaboration/src/browser/cursor-widget.ts index a9bcf4ab32..925acb2436 100644 --- a/packages/collaboration/src/browser/cursor-widget.ts +++ b/packages/collaboration/src/browser/cursor-widget.ts @@ -9,31 +9,7 @@ import { IContentWidgetPosition, } from '@opensumi/monaco-editor-core/esm/vs/editor/browser/editorBrowser'; -import { UserInfo } from '../common'; - -import { getColorByClientID } from './color'; - -export interface ICursorWidgetRegistry { - /** - * update specified position of widget, but not invoke `layoutWidget` - * @param id - * @param pos - */ - updatePositionOf(clientID: number, lineNumber: number, column: number): void; - /** - * set all position of widget to null - * @param editor - */ - removeAllPositions(editor: ICodeEditor): void; - /** - * update all position of widget, `layoutWidget` is invoked - */ - layoutAllWidgets(): void; - /** - * destroy this registry and all its widgets - */ - destroy(): void; -} +import { ICursorWidgetRegistry, UserInfo, Y_REMOTE_SELECTION } from '../common'; const createPositionFrom = (lineNumber: number, column: number): IContentWidgetPosition => ({ position: { lineNumber, column }, @@ -155,7 +131,7 @@ export class CursorWidget implements IContentWidget { this.domNode = document.createElement('div'); this.domNode.innerHTML = nickname; this.domNode.style.opacity = '1'; - this.domNode.className = `yRemoteSelection-${clientID}`; + this.domNode.className = `${Y_REMOTE_SELECTION}-${clientID}`; // set id this.id = `cursor-widget-${nickname}`; } diff --git a/packages/collaboration/src/browser/textmodel-binding.ts b/packages/collaboration/src/browser/textmodel-binding.ts index c2d8c927c1..bddf9f947b 100644 --- a/packages/collaboration/src/browser/textmodel-binding.ts +++ b/packages/collaboration/src/browser/textmodel-binding.ts @@ -13,7 +13,7 @@ import { IDisposable, } from '@opensumi/monaco-editor-core/esm/vs/editor/editor.api'; -import { ICollaborationService, UserInfo } from '../common'; +import { ICollaborationService, UserInfo, Y_REMOTE_SELECTION, Y_REMOTE_SELECTION_HEAD } from '../common'; import { CollaborationService } from './collaboration.service'; import { CursorWidgetRegistry } from './cursor-widget'; @@ -95,20 +95,20 @@ export class TextModelBinding { if (anchorAbs.index < headAbs.index) { start = this.textModel.getPositionAt(anchorAbs.index); end = this.textModel.getPositionAt(headAbs.index); - afterContentClassName = 'yRemoteSelectionHead yRemoteSelectionHead-' + clientID; + afterContentClassName = `${Y_REMOTE_SELECTION} ${Y_REMOTE_SELECTION_HEAD}-${clientID}`; beforeContentClassName = null; } else { start = this.textModel.getPositionAt(headAbs.index); end = this.textModel.getPositionAt(anchorAbs.index); afterContentClassName = null; - beforeContentClassName = 'yRemoteSelectionHead yRemoteSelectionHead-' + clientID; + beforeContentClassName = `${Y_REMOTE_SELECTION_HEAD} ${Y_REMOTE_SELECTION_HEAD}-${clientID}`; } newDecorations.push({ range: new Range(start.lineNumber, start.column, end.lineNumber, end.column), options: { description: 'yjs decoration ' + clientID, - className: 'yRemoteSelection yRemoteSelection-' + clientID, + className: `${Y_REMOTE_SELECTION} ${Y_REMOTE_SELECTION}-${clientID}`, afterContentClassName, beforeContentClassName, }, diff --git a/packages/collaboration/src/common/index.ts b/packages/collaboration/src/common/index.ts index 5eae20b64f..1918cfdbb3 100644 --- a/packages/collaboration/src/common/index.ts +++ b/packages/collaboration/src/common/index.ts @@ -1,5 +1,7 @@ import * as Y from 'yjs'; +import { ICodeEditor } from '@opensumi/ide-monaco'; + export const ICollaborationService = Symbol('ICollaborationService'); export interface ICollaborationService { @@ -40,3 +42,26 @@ export interface UserInfo { nickname: string; // will be displayed on live cursor // may be more data fields } + +export interface ICursorWidgetRegistry { + /** + * update specified position of widget, but not invoke `layoutWidget` + */ + updatePositionOf(clientID: number, lineNumber: number, column: number): void; + /** + * set all position of widget to null + * @param editor + */ + removeAllPositions(editor: ICodeEditor): void; + /** + * update all position of widget, `layoutWidget` is invoked + */ + layoutAllWidgets(): void; + /** + * destroy this registry and all its widgets + */ + destroy(): void; +} + +export const Y_REMOTE_SELECTION = 'yRemoteSelection'; +export const Y_REMOTE_SELECTION_HEAD = 'yRemoteSelectionHead'; From fa84f068fcb51214f7b08d67edebfaaf39e1585d Mon Sep 17 00:00:00 2001 From: situ2001 Date: Fri, 12 Aug 2022 20:27:47 +0800 Subject: [PATCH 4/4] test: update --- .../__tests__/browser/textmodel-binding.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/collaboration/__tests__/browser/textmodel-binding.test.ts b/packages/collaboration/__tests__/browser/textmodel-binding.test.ts index fef209ca83..e036972e66 100644 --- a/packages/collaboration/__tests__/browser/textmodel-binding.test.ts +++ b/packages/collaboration/__tests__/browser/textmodel-binding.test.ts @@ -3,16 +3,28 @@ import { Awareness } from 'y-protocols/awareness'; import { WebsocketProvider } from 'y-websocket'; import * as Y from 'yjs'; +import { Injector } from '@opensumi/di'; import { uuid } from '@opensumi/ide-core-common'; import { ICodeEditor } from '@opensumi/ide-monaco'; import * as monaco from '@opensumi/monaco-editor-core/esm/vs/editor/editor.api'; import { TextModelBinding } from '../../src/browser/textmodel-binding'; +import { ICollaborationService } from '../../src/common'; + +const injector = new Injector(); + +injector.addProviders({ + token: ICollaborationService, + useValue: { + getCursorWidgetRegistry: jest.fn(), + }, +}); const createBindingWithTextModel = (doc: Y.Doc, awareness: Awareness) => { const textModel = monaco.editor.createModel(''); const yText = doc.getText('test'); - const binding = new TextModelBinding(yText, textModel, awareness); + // const binding = new TextModelBinding(yText, textModel, awareness); + const binding = injector.get(TextModelBinding, [yText, textModel, awareness]); return { textModel, binding, @@ -31,6 +43,7 @@ describe('TextModelBinding test for yText and TextModel', () => { wsProvider = new WebsocketProvider('ws://127.0.0.1:12345', 'test', doc, { connect: false }); // we don't use wsProvider here user1 = createBindingWithTextModel(doc, wsProvider.awareness); user2 = createBindingWithTextModel(doc, wsProvider.awareness); + jest.mock('@opensumi/di'); }); afterEach(() => {