diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 46d3481ac..c653afe6c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -77,7 +77,7 @@ $ yarn # install local np ### Start Dev Server ```bash -$ cp configs/firebase.sample.json configs/firebase.json # copy dummy config file +$ cp configs/firebase.json.sample configs/firebase.json # copy dummy config file $ vi configs/firebase.json # update configuration diff --git a/.github/workflows/npm-deploy.yml b/.github/workflows/npm-deploy.yml index 554c7582c..c6a145772 100644 --- a/.github/workflows/npm-deploy.yml +++ b/.github/workflows/npm-deploy.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - main + - vscode-firepad jobs: build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b8635ae3..5a02f7208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,18 @@ Tags: Good to have: commit or PR links. --> +## v0.5.2-beta [#56](https://github.com/interviewstreet/firepad-x/pull/56) + +### Changed + +- Added MonacoEditorUtilAdapter to support native monaco and monaco from vscode + +## v0.4.2-beta [#55](https://github.com/interviewstreet/firepad-x/pull/55) + +### Changed + +- Added `Firepd.enable()` and `Firepad.disable()` API methods for enbling and disabling firepad without disposing. +- No breaking changes on the API level. ## v0.4.1-beta @@ -30,11 +42,11 @@ No Changes ### Changed -- Fix a bug monaco-adapter/_operationFromMonacoChange function +- Fix a bug monaco-adapter/\_operationFromMonacoChange function - Revert https://github.com/interviewstreet/firepad-x/commit/2aebf79871d3fc9bf21e9b056a0faa60c6da0b3a - Revert https://github.com/interviewstreet/firepad-x/commit/a12177892c692b44c106d60f2819fbf7f1094f22 - No breaking change on external APIs. -- Revert to firebase v7.12.0 (https://github.com/interviewstreet/firepad-x/commit/03910ce475e04ddcab2e683877f579bfcbd32d91). +- Revert to firebase v7.12.0 (https://github.com/interviewstreet/firepad-x/commit/03910ce475e04ddcab2e683877f579bfcbd32d91). ## v0.3.1 [#44](https://github.com/interviewstreet/firepad-x/pull/44) diff --git a/package.json b/package.json index 05bdf4a64..33fcce170 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hackerrank/firepad", "description": "Collaborative text editing powered by Firebase", - "version": "0.7.4-beta", + "version": "0.8.1-beta", "author": { "email": "bprogyan@gmail.com", "name": "Progyan Bhattacharya", diff --git a/src/cursor-widget-controller.ts b/src/cursor-widget-controller.ts index 0bec234fd..d7de9ec49 100644 --- a/src/cursor-widget-controller.ts +++ b/src/cursor-widget-controller.ts @@ -2,6 +2,7 @@ import * as monaco from "monaco-editor"; import { CursorWidget, ICursorWidget } from "./cursor-widget"; import { ClientIDType } from "./editor-adapter"; +import { IMonacoEditorUtilsAdapter } from "./monaco-editor-utils"; import { IDisposable } from "./utils"; export interface ICursorWidgetController extends IDisposable { @@ -42,11 +43,13 @@ export class CursorWidgetController implements ICursorWidgetController { protected readonly _cursors: Map; protected readonly _editor: monaco.editor.IStandaloneCodeEditor; protected readonly _tooltipDuration: number; + protected readonly _editorUtils: IMonacoEditorUtilsAdapter; - constructor(editor: monaco.editor.IStandaloneCodeEditor) { + constructor(editor: monaco.editor.IStandaloneCodeEditor, editorUtils: IMonacoEditorUtilsAdapter) { this._editor = editor; this._tooltipDuration = 1000; this._cursors = new Map(); + this._editorUtils = editorUtils; } addCursor( @@ -62,6 +65,7 @@ export class CursorWidgetController implements ICursorWidgetController { range, label: userName || clientId.toString(), tooltipDuration: this._tooltipDuration, + editorUtils: this._editorUtils, onDisposed: () => { this.removeCursor(clientId); }, diff --git a/src/cursor-widget.ts b/src/cursor-widget.ts index dfac6e265..110c831ac 100644 --- a/src/cursor-widget.ts +++ b/src/cursor-widget.ts @@ -10,6 +10,7 @@ import * as monaco from "monaco-editor"; import { ClientIDType } from "./editor-adapter"; +import { IMonacoEditorUtilsAdapter } from "./monaco-editor-utils"; import * as Utils from "./utils"; type OnDisposed = Utils.VoidFunctionType; @@ -22,6 +23,7 @@ export interface ICursorWidgetConstructorOptions { range: monaco.Range; tooltipDuration?: number; opacity?: string; + editorUtils: IMonacoEditorUtilsAdapter; onDisposed: OnDisposed; } @@ -56,6 +58,7 @@ export class CursorWidget implements ICursorWidget { protected readonly _tooltipDuration: number; protected readonly _scrollListener: monaco.IDisposable | null; protected readonly _onDisposed: OnDisposed; + protected readonly _editorUtils: IMonacoEditorUtilsAdapter; protected _tooltipNode: HTMLElement; protected _color: string; @@ -76,7 +79,8 @@ export class CursorWidget implements ICursorWidget { range, tooltipDuration = 1000, opacity = "1.0", - onDisposed, + editorUtils, + onDisposed }: ICursorWidgetConstructorOptions) { this._editor = codeEditor; this._tooltipDuration = tooltipDuration; @@ -85,6 +89,7 @@ export class CursorWidget implements ICursorWidget { this._color = color; this._content = label; this._opacity = opacity; + this._editorUtils = editorUtils; this._domNode = this._createWidgetNode(); @@ -148,8 +153,8 @@ export class CursorWidget implements ICursorWidget { this._position = { position: range.getEndPosition(), preference: [ - monaco.editor.ContentWidgetPositionPreference.ABOVE, - monaco.editor.ContentWidgetPositionPreference.BELOW, + this._editorUtils.getContentWidgetPositionPreference('ABOVE'), + this._editorUtils.getContentWidgetPositionPreference('BELOW'), ], }; diff --git a/src/firebase-adapter.ts b/src/firebase-adapter.ts index 02a850098..d9bb95832 100644 --- a/src/firebase-adapter.ts +++ b/src/firebase-adapter.ts @@ -156,6 +156,14 @@ export class FirebaseAdapter implements IDatabaseAdapter { this._init(); } + public enable() { + this._zombie = false; + } + + public disable() { + this._zombie = true; + } + protected _init(): void { const connectedRef = this._databaseRef!.root.child(".info/connected"); @@ -393,7 +401,19 @@ export class FirebaseAdapter implements IDatabaseAdapter { // We have an outstanding change at this revision id. if ( this._sent.op.equals(revision.operation) && - revision.author == this._userId + /** + * Adding the OR revisionId === "A0" condition because when firepad is + * initialized, both clients call Firepad.setText method which results + * in an operation being created for the default editor content of both the + * clients. If default editor content is same because of any code stub or + * if we have set some default value in monaco editor, this leads to the + * same default code appearing twice. To fix this, we make an assumption that + * if the sent op is same as received op and if it's the first op (A0), then + * it was probably a code stub. This condition will not hold true if both the + * clients input the same character after being initialized on purpose and want + * that content to be replicated twice. This however seems unlikely. + */ + (revision.author == this._userId || revisionId === "A0") ) { // This is our change; it succeeded. if (this._revision % FirebaseAdapter.CHECKPOINT_FREQUENCY === 0) { diff --git a/src/firepad-classic.ts b/src/firepad-classic.ts index a696ee170..09cc1d355 100644 --- a/src/firepad-classic.ts +++ b/src/firepad-classic.ts @@ -96,6 +96,26 @@ export default class FirepadClassic implements IFirepad { this.init(); } + public enable(): void { + throw new Error("Method not implemented."); + } + + public disable(): void { + throw new Error("Method not implemented."); + } + + public beforeApplyChanges( + callback: (changes: monaco.editor.IIdentifiedSingleEditOperation[]) => void + ): void { + throw new Error("Method not implemented."); + } + + public afterApplyChanges( + callback: (changes: monaco.editor.IIdentifiedSingleEditOperation[]) => void + ): void { + throw new Error("Method not implemented."); + } + protected init(): void { this._databaseAdapter.on(DatabaseAdapterEvent.Ready, () => { this._ready = true; diff --git a/src/firepad-monaco.ts b/src/firepad-monaco.ts index 7a8bc2ba0..76521904b 100644 --- a/src/firepad-monaco.ts +++ b/src/firepad-monaco.ts @@ -6,6 +6,7 @@ import { IDatabaseAdapter, UserIDType } from "./database-adapter"; import { FirebaseAdapter } from "./firebase-adapter"; import { Firepad, IFirepad, IFirepadConstructorOptions } from "./firepad"; import { MonacoAdapter } from "./monaco-adapter"; +import { IMonacoEditorUtilsAdapter, NativeMonacoEditorUtils } from "./monaco-editor-utils"; import * as Utils from "./utils"; import { FirestoreAdapter } from "./firestore-adapter"; @@ -18,7 +19,8 @@ import { FirestoreAdapter } from "./firestore-adapter"; export function fromMonacoWithFirebase( databaseRef: string | firebase.database.Reference, editor: monaco.editor.IStandaloneCodeEditor, - options: Partial = {} + options: Partial = {}, + editorUtils: IMonacoEditorUtilsAdapter = new NativeMonacoEditorUtils() ): IFirepad { // Initialize constructor options with their default values const userId: UserIDType = options.userId || uuid(); @@ -34,7 +36,7 @@ export function fromMonacoWithFirebase( userName ); - const editorAdapter = new MonacoAdapter(editor, false); + const editorAdapter = new MonacoAdapter(editor, false, editorUtils); return new Firepad(databaseAdapter, editorAdapter, { userId, userName, diff --git a/src/firepad.ts b/src/firepad.ts index 8d77772c9..cb5cbfa6a 100644 --- a/src/firepad.ts +++ b/src/firepad.ts @@ -1,3 +1,4 @@ +import { editor } from "monaco-editor"; import { Cursor } from "./cursor"; import { DatabaseAdapterEvent, @@ -13,6 +14,8 @@ import { IEditorClientEvent, } from "./editor-client"; import { EventEmitter, EventListenerType, IEventEmitter } from "./emitter"; +import { FirebaseAdapter } from "./firebase-adapter"; +import { MonacoAdapter } from "./monaco-adapter"; import * as Utils from "./utils"; export enum FirepadEvent { @@ -102,13 +105,29 @@ export interface IFirepad extends Utils.IDisposable { * @param option - Configuration option (same as constructor). */ getConfiguration(option: keyof IFirepadConstructorOptions): any; + /** + *Enable firepad if it is disabled. + */ + enable(): void; + /** + * Disable firepad without destroying it. + */ + disable(): void; + + beforeApplyChanges( + callback: (changes: editor.IIdentifiedSingleEditOperation[]) => void + ): void; + + afterApplyChanges( + callback: (changes: editor.IIdentifiedSingleEditOperation[]) => void + ): void; } export class Firepad implements IFirepad { protected readonly _options: IFirepadConstructorOptions; protected readonly _editorClient: IEditorClient; - protected readonly _editorAdapter: IEditorAdapter; - protected readonly _databaseAdapter: IDatabaseAdapter; + protected readonly _editorAdapter: MonacoAdapter; + protected readonly _databaseAdapter: FirebaseAdapter; protected _ready: boolean; protected _zombie: boolean; @@ -134,8 +153,8 @@ export class Firepad implements IFirepad { this._zombie = false; this._options = options; - this._databaseAdapter = databaseAdapter; - this._editorAdapter = editorAdapter; + this._databaseAdapter = databaseAdapter as FirebaseAdapter; + this._editorAdapter = editorAdapter as MonacoAdapter; this._editorClient = new EditorClient(databaseAdapter, editorAdapter); this._emitter = new EventEmitter([ @@ -151,15 +170,17 @@ export class Firepad implements IFirepad { protected _init(): void { this._databaseAdapter.on(DatabaseAdapterEvent.Ready, () => { - this._ready = true; + if (!this._zombie) { + this._ready = true; - const { defaultText } = this._options; - if (defaultText && this.isHistoryEmpty()) { - this.setText(defaultText); - this.clearUndoRedoStack(); - } + const { defaultText } = this._options; + if (defaultText && this.isHistoryEmpty()) { + this.setText(defaultText); + this.clearUndoRedoStack(); + } - this._trigger(FirepadEvent.Ready, true); + this._trigger(FirepadEvent.Ready, true); + } }); this._editorClient.on( @@ -203,6 +224,28 @@ export class Firepad implements IFirepad { ); } + public enable() { + this._databaseAdapter.enable(); + this._editorAdapter.enable(); + } + + public disable() { + this._databaseAdapter.disable(); + this._editorAdapter.disable(); + } + + public beforeApplyChanges( + callback: (changes: editor.IIdentifiedSingleEditOperation[]) => void + ) { + this._editorAdapter.beforeApplyChanges(callback); + } + + public afterApplyChanges( + callback: (changes: editor.IIdentifiedSingleEditOperation[]) => void + ) { + this._editorAdapter.afterApplyChanges(callback); + } + getConfiguration(option: keyof IFirepadConstructorOptions): any { return option in this._options ? this._options[option] : null; } diff --git a/src/index.ts b/src/index.ts index bc1162173..fcc8c86ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,5 +6,6 @@ export * from "./firepad"; export * from "./firepad-monaco"; export * from "./monaco-adapter"; export * from "./text-operation"; +export * from "./monaco-editor-utils"; export { Firepad as default } from "./firepad"; diff --git a/src/monaco-adapter.ts b/src/monaco-adapter.ts index 17295b495..93bdde97a 100644 --- a/src/monaco-adapter.ts +++ b/src/monaco-adapter.ts @@ -1,5 +1,4 @@ import * as monaco from "monaco-editor"; - import { Cursor, ICursor } from "./cursor"; import { CursorWidgetController, @@ -14,6 +13,7 @@ import { UndoRedoCallbackType, } from "./editor-adapter"; import { EventEmitter, EventListenerType, IEventEmitter } from "./emitter"; +import { IMonacoEditorUtilsAdapter, NativeMonacoEditorUtils } from "./monaco-editor-utils"; import { ITextOp } from "./text-op"; import { ITextOperation, TextOperation } from "./text-operation"; import * as Utils from "./utils"; @@ -34,7 +34,9 @@ export class MonacoAdapter implements IEditorAdapter { protected readonly _disposables: monaco.IDisposable[]; protected readonly _remoteCursors: Map; protected readonly _cursorWidgetController: ICursorWidgetController; + protected readonly _editorUtils: IMonacoEditorUtilsAdapter; + protected _isDisabled: boolean = false; protected _ignoreChanges: boolean; protected _lastDocLines: string[]; protected _lastCursorRange: monaco.Selection | null; @@ -44,6 +46,15 @@ export class MonacoAdapter implements IEditorAdapter { protected _originalUndo: UndoRedoCallbackType | null; protected _originalRedo: UndoRedoCallbackType | null; protected _initiated: boolean; + protected operationsToBeApplied: ITextOperation[] = []; + + protected beforeApplyChangesCallbacks: (( + changes: monaco.editor.IIdentifiedSingleEditOperation[] + ) => void)[] = []; + + protected afterApplyChangesCallbacks: (( + changes: monaco.editor.IIdentifiedSingleEditOperation[] + ) => void)[] = []; /** * Wraps a monaco editor in adapter to work with rest of Firepad @@ -52,7 +63,8 @@ export class MonacoAdapter implements IEditorAdapter { */ constructor( monacoInstance: monaco.editor.IStandaloneCodeEditor, - avoidListeners: boolean = true + avoidListeners: boolean = true, + editorUtils: IMonacoEditorUtilsAdapter = new NativeMonacoEditorUtils() ) { this._classNames = []; this._disposables = []; @@ -60,7 +72,8 @@ export class MonacoAdapter implements IEditorAdapter { this._lastDocLines = this._monaco.getModel()?.getLinesContent() || [""]; this._lastCursorRange = this._monaco.getSelection(); this._remoteCursors = new Map(); - this._cursorWidgetController = new CursorWidgetController(this._monaco); + this._editorUtils = editorUtils; + this._cursorWidgetController = new CursorWidgetController(this._monaco, editorUtils); this._redoCallback = null; this._undoCallback = null; @@ -84,6 +97,20 @@ export class MonacoAdapter implements IEditorAdapter { EditorAdapterEvent.Undo, ]); + this._initMonacoEvents(); + } + + public enable() { + this._isDisabled = false; + this._ignoreChanges = false; + this._initMonacoEvents(); + this.operationsToBeApplied.forEach((operation) => { + this.applyOperation(operation); + }); + this.operationsToBeApplied = []; + } + + private _initMonacoEvents() { this._disposables.push( this._cursorWidgetController, this._monaco.onDidBlurEditorWidget(() => { @@ -108,6 +135,25 @@ export class MonacoAdapter implements IEditorAdapter { ); } + public disable() { + this._isDisabled = true; + this._ignoreChanges = true; + this._disposables.forEach((disposable) => disposable.dispose()); + this._disposables.splice(0, this._disposables.length); + } + + public beforeApplyChanges( + callback: (changes: monaco.editor.IIdentifiedSingleEditOperation[]) => void + ): void { + this.beforeApplyChangesCallbacks.push(callback); + } + + public afterApplyChanges( + callback: (changes: monaco.editor.IIdentifiedSingleEditOperation[]) => void + ): void { + this.afterApplyChangesCallbacks.push(callback); + } + dispose(): void { this._remoteCursors.clear(); this._disposables.forEach((disposable) => disposable.dispose()); @@ -244,7 +290,7 @@ export class MonacoAdapter implements IEditorAdapter { /** Create Selection in the Editor */ this._monaco.setSelection( - new monaco.Range( + new this._editorUtils.Range( start.lineNumber, start.column, end.lineNumber, @@ -320,7 +366,7 @@ export class MonacoAdapter implements IEditorAdapter { } /** Find Range of Selection */ - const range = new monaco.Range( + const range = new this._editorUtils.Range( start.lineNumber, start.column, end.lineNumber, @@ -337,7 +383,7 @@ export class MonacoAdapter implements IEditorAdapter { className, isWholeLine: false, stickiness: - monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + this._editorUtils.getTrackedRangeStickiness('NeverGrowsWhenTypingAtEdges'), }, }, ] @@ -464,7 +510,7 @@ export class MonacoAdapter implements IEditorAdapter { /** Insert Operation */ const pos = model.getPositionAt(index); changes.push({ - range: new monaco.Range( + range: new this._editorUtils.Range( pos.lineNumber, pos.column, pos.lineNumber, @@ -482,7 +528,7 @@ export class MonacoAdapter implements IEditorAdapter { const to = model.getPositionAt(index + op.chars!); changes.push({ - range: new monaco.Range( + range: new this._editorUtils.Range( from.lineNumber, from.column, to.lineNumber, @@ -513,7 +559,7 @@ export class MonacoAdapter implements IEditorAdapter { ({ readOnly } = this._monaco.getConfiguration()); } else { // @ts-ignore - Remove this after monaco upgrade - readOnly = this._monaco.getOption(monaco.editor.EditorOption.readOnly); + readOnly = this._monaco.getOption(this._editorUtils.getEditorOption('readOnly')); } if (readOnly) { @@ -527,7 +573,12 @@ export class MonacoAdapter implements IEditorAdapter { } } - applyOperation(operation: ITextOperation): void { + applyOperation(operation: ITextOperation) { + if (this._isDisabled) { + this.operationsToBeApplied.push(operation); + return; + } + if (!operation.isNoop()) { this._ignoreChanges = true; } @@ -543,6 +594,8 @@ export class MonacoAdapter implements IEditorAdapter { model ); + this.beforeApplyChangesCallbacks.forEach((cb) => cb(changes)) + /** Changes exists to be applied */ if (changes.length) { this._applyChangesToMonaco(changes); @@ -554,6 +607,8 @@ export class MonacoAdapter implements IEditorAdapter { } this._ignoreChanges = false; + + this.afterApplyChangesCallbacks.forEach((cb) => cb(changes)) } invertOperation(operation: ITextOperation): ITextOperation { @@ -575,7 +630,7 @@ export class MonacoAdapter implements IEditorAdapter { protected _onCursorActivity( ev: monaco.editor.ICursorPositionChangedEvent ): void { - if (ev.reason === monaco.editor.CursorChangeReason.RecoverFromMarkers) { + if (ev.reason === this._editorUtils.getCursorChangeReason('RecoverFromMarkers')) { return; } @@ -613,6 +668,10 @@ export class MonacoAdapter implements IEditorAdapter { } protected _onModelChange(_ev: monaco.editor.IModelChangedEvent): void { + if (this._isDisabled) { + return; + } + const newModel = this._getModel(); if (!newModel) { @@ -631,7 +690,7 @@ export class MonacoAdapter implements IEditorAdapter { const oldLinesCount = this._lastDocLines.length; const oldLastColumLength = this._lastDocLines[oldLinesCount - 1].length; - const oldRange = new monaco.Range( + const oldRange = new this._editorUtils.Range( 1, 1, oldLinesCount, diff --git a/src/monaco-editor-utils.ts b/src/monaco-editor-utils.ts new file mode 100644 index 000000000..5f9695b48 --- /dev/null +++ b/src/monaco-editor-utils.ts @@ -0,0 +1,42 @@ +import * as monaco from "monaco-editor"; + +/** + * Monaco editor is built from vscode source, If we use firepad-x in vscode + * we cannot add monaco-editor as a dependency as it will increase the bundle size + * and we are just adding duplicate code which already exists in vscode + * + * So we have created a adapter which provides native monaco editor utils + * when used without vscode and when used with vscode the vscode consumer + * provide vscode specific editor utils + * + * For a normal consumer of firepad this should make no difference as + * NativeMonacoEditorUtils is used by default + */ +export interface IMonacoEditorUtilsAdapter { + getTrackedRangeStickiness(option: keyof typeof monaco.editor.TrackedRangeStickiness): monaco.editor.TrackedRangeStickiness; + getEditorOption(option: string): any; + getCursorChangeReason(option: keyof typeof monaco.editor.CursorChangeReason): monaco.editor.CursorChangeReason; + getContentWidgetPositionPreference(option: keyof typeof monaco.editor.ContentWidgetPositionPreference): monaco.editor.ContentWidgetPositionPreference; + Range: typeof monaco.Range; +} + +export class NativeMonacoEditorUtils implements IMonacoEditorUtilsAdapter { + getTrackedRangeStickiness(option: keyof typeof monaco.editor.TrackedRangeStickiness): monaco.editor.TrackedRangeStickiness { + return monaco.editor.TrackedRangeStickiness[option]; + } + + getEditorOption(option: string) { + // @ts-ignore + return monaco.editor.EditorOption[option]; + } + + getCursorChangeReason(option: keyof typeof monaco.editor.CursorChangeReason): monaco.editor.CursorChangeReason { + return monaco.editor.CursorChangeReason[option]; + } + + getContentWidgetPositionPreference(option: keyof typeof monaco.editor.ContentWidgetPositionPreference): monaco.editor.ContentWidgetPositionPreference { + return monaco.editor.ContentWidgetPositionPreference[option]; + } + + Range = monaco.Range; +}