Initial commit.

pull/2/head
Michael MacFadden 5 years ago
commit c743f8df2a

@ -0,0 +1,17 @@
{
"presets": [
["@babel/env", {
"targets": {
"browsers": ["last 2 versions"]
}
}],
"@babel/typescript"
],
"plugins": [
"transform-class-properties",
["module-resolver", {
"extensions": [".js", ".ts"],
"root": ["./src/js"]
}]
]
}

3
.gitignore vendored

@ -0,0 +1,3 @@
.idea
node_modules
dist

@ -0,0 +1,6 @@
language: node_js
node_js:
- "10.10"
script: npm run dist

@ -0,0 +1,8 @@
# Change Log
## [v0.1.0](https://github.com/convergencelabs/monaco-collab-ext/tree/0.1.0) (2019-03-03)
- Initial release.

@ -0,0 +1,18 @@
Copyright (c) 2019 Convergence Labs, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH
THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

@ -0,0 +1,116 @@
## Monaco Collaborative Extensions
[![Build Status](https://travis-ci.org/convergencelabs/monaco-collab-ext.svg?branch=master)](https://travis-ci.org/convergencelabs/monaco-collab-ext)
Enhances the [Monaco Editor](https://github.com/Microsoft/monaco-editor) by adding the ability to render cues about what remote users are doing in the system.
![demo graphic](./docs/demo.gif "Shared Cursors and Selections")
## Installation
Install package with NPM and add it to your development dependencies:
```npm install --save-dev @convergence/ace-collab-ext```
## Demo
Go [here](https://examples.convergence.io/monaco/index.html) to see a live demo of multiple cursors, multiple selections, and remote scrollbars (Visit on multiple browsers, or even better, point a friend to it too). This uses [Convergence](https://convergence.io) to handle the synchronization of data and user actions.
## Usage
### RemoteCursorManager
The RemoteCursorManager allows you to easily render the cursors of other users
working in the same document. The cursor position can be represented as either
a single linear index or as a 2-dimensional position in the form of
```{lineNumber: 0, column: 10}```.
```JavaScript
const editor = monaco.editor.create(document.getElementById("editor"), {
value: "function helloWolrd = () => { console.log('hello world!')",
theme: "vs-dark'",
language: 'javascript'
});
const remoteCursorManager = new MonacoCollabExt.RemoteCursorManager({
editor: editor,
tooltips: true,
tooltipDuration: 2
});
const cursor = remoteCursorManager.addCursor("jDoe", "blue", "John Doe");
// Set the position of the cursor.
cursor.setOffset(4);
// Hide the cursor
cursor.hide();
// Show the cursor
cursor.show();
// Remove the cursor.
cursor.dispose();
```
### RemoteSelectionManager
The RemoteSelectionManager allows you to easily render the selection of other
users working in the same document.
```JavaScript
const editor = monaco.editor.create(document.getElementById("editor"), {
value: "function helloWolrd = () => { console.log('hello world!')",
theme: "vs-dark'",
language: 'javascript'
});
const remoteSelectionManager = new MonacoCollabExt.RemoteSelectionManager({editor: editor});
const selection = remoteSelectionManager.addSelection("jDoe", "blue");
// Set the range of the selection using zero-based offsets.
selection.setOffsets(45, 55);
// Hide the selection
selection.hide();
// Show the selection
selection.show();
// Remove the selection.
selection.dispose();
```
### EditorContentManager
The EditorContentManager simplifies dealing with local and remote changes
to the editor.
```JavaScript
const editor = monaco.editor.create(document.getElementById("editor"), {
value: "function helloWolrd = () => { console.log('hello world!')",
theme: "vs-dark'",
language: 'javascript'
});
const contentManager = new MonacoCollabExt.EditorContentManager({
editor: editor,
onInsert(index, text) {
console.log("Insert", index, text);
},
onReplace(index, length, text) {
console.log("Replace", index, length, text);
},
onDelete(index, length) {
console.log("Delete", index, length);
}
});
// Insert text into the editor at offset 5.
contentManager.insert(5, "some text");
// Replace the text in the editor at range 5 - 10.
contentManager.replace(5, 10, "some text");
// Delete the text in the editor at range 5 - 10.
contentManager.delete(5, 10);
// Release resources when done
contentManager.dispose();
```

@ -0,0 +1,5 @@
/**!
© 2019 Convergence Labs, Inc.
@version <%= package.version %>
@license MIT
*/

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

@ -0,0 +1,2 @@
# Monaco Collaborative Extensions Example
This directory contains a vanilla javascript example of using this library. Simply run `npm run dist` and then open the index.html in a web browser.

@ -0,0 +1,84 @@
var editorContents = `var observableProto;
/**
* Represents a push-style collection.
*/
var Observable = Rx.Observable = (function () {
function makeSubscribe(self, subscribe) {
return function (o) {
var oldOnError = o.onError;
o.onError = function (e) {
makeStackTraceLong(e, self);
oldOnError.call(o, e);
};
return subscribe.call(self, o);
};
}
function Observable() {
if (Rx.config.longStackSupport && hasStacks) {
var oldSubscribe = this._subscribe;
var e = tryCatch(thrower)(new Error()).e;
this.stack = e.stack.substring(e.stack.indexOf('\\n') + 1);
this._subscribe = makeSubscribe(this, oldSubscribe);
}
}
observableProto = Observable.prototype;
/**
* Determines whether the given object is an Observable
* @param {Any} An object to determine whether it is an Observable
* @returns {Boolean} true if an Observable, else false.
*/
Observable.isObservable = function (o) {
return o && isFunction(o.subscribe);
};
/**
* Subscribes an o to the observable sequence.
* @param {Mixed} [oOrOnNext] The object that is to receive notifications or an action to invoke for each element in the observable sequence.
* @param {Function} [onError] Action to invoke upon exceptional termination of the observable sequence.
* @param {Function} [onCompleted] Action to invoke upon graceful termination of the observable sequence.
* @returns {Diposable} A disposable handling the subscriptions and unsubscriptions.
*/
observableProto.subscribe = observableProto.forEach = function (oOrOnNext, onError, onCompleted) {
return this._subscribe(typeof oOrOnNext === 'object' ?
oOrOnNext :
observerCreate(oOrOnNext, onError, onCompleted));
};
/**
* Subscribes to the next value in the sequence with an optional "this" argument.
* @param {Function} onNext The function to invoke on each element in the observable sequence.
* @param {Any} [thisArg] Object to use as this when executing callback.
* @returns {Disposable} A disposable handling the subscriptions and unsubscriptions.
*/
observableProto.subscribeOnNext = function (onNext, thisArg) {
return this._subscribe(observerCreate(typeof thisArg !== 'undefined' ? function(x) { onNext.call(thisArg, x); } : onNext));
};
/**
* Subscribes to an exceptional condition in the sequence with an optional "this" argument.
* @param {Function} onError The function to invoke upon exceptional termination of the observable sequence.
* @param {Any} [thisArg] Object to use as this when executing callback.
* @returns {Disposable} A disposable handling the subscriptions and unsubscriptions.
*/
observableProto.subscribeOnError = function (onError, thisArg) {
return this._subscribe(observerCreate(null, typeof thisArg !== 'undefined' ? function(e) { onError.call(thisArg, e); } : onError));
};
/**
* Subscribes to the next value in the sequence with an optional "this" argument.
* @param {Function} onCompleted The function to invoke upon graceful termination of the observable sequence.
* @param {Any} [thisArg] Object to use as this when executing callback.
* @returns {Disposable} A disposable handling the subscriptions and unsubscriptions.
*/
observableProto.subscribeOnCompleted = function (onCompleted, thisArg) {
return this._subscribe(observerCreate(null, null, typeof thisArg !== 'undefined' ? function() { onCompleted.call(thisArg); } : onCompleted));
};
return Observable;
})();`;

@ -0,0 +1,19 @@
.body {
margin: 0;
}
.editors {
display: flex;
flex-direction: row;
flex: 1;
}
.editor-column {
flex: 1;
}
.editor {
height: 500px;
border: 1px solid grey;
margin-right: 20px;
}

@ -0,0 +1,80 @@
const sourceUser = {
id: "source",
label: "Source User",
color: "orange"
};
const staticUser = {
id: "static",
label: "Static User",
color: "blue"
};
require.config({ paths: { 'vs': '../node_modules/monaco-editor/min/vs' }});
require(['vs/editor/editor.main', 'MonacoCollabExt'], function(m, MonacoCollabExt) {
//
// Create the target editor where events will be played into.
//
const target = monaco.editor.create(document.getElementById("target-editor"), {
value: editorContents,
theme: "vs-dark'",
language: 'javascript'
});
const remoteCursorManager = new MonacoCollabExt.RemoteCursorManager({
editor: target,
tooltips: true,
tooltipDuration: 2
});
const sourceUserCursor = remoteCursorManager.addCursor(sourceUser.id, sourceUser.color, sourceUser.label);
const staticUserCursor = remoteCursorManager.addCursor(staticUser.id, staticUser.color, staticUser.label);
const remoteSelectionManager = new MonacoCollabExt.RemoteSelectionManager({editor: target});
remoteSelectionManager.addSelection(sourceUser.id, sourceUser.color);
remoteSelectionManager.addSelection(staticUser.id, staticUser.color);
const targetContentManager = new MonacoCollabExt.EditorContentManager({
editor: target
});
//
// Faked other user.
//
staticUserCursor.setOffset(50);
remoteSelectionManager.setSelectionOffsets(staticUser.id, 40, 50);
//
// Create the source editor were events will be generated.
//
const source = monaco.editor.create(document.getElementById("source-editor"), {
value: editorContents,
theme: "vs-dark'",
language: 'javascript'
});
source.onDidChangeCursorPosition(e => {
const offset = source.getModel().getOffsetAt(e.position);
sourceUserCursor.setOffset(offset);
});
source.onDidChangeCursorSelection(e => {
const startOffset = source.getModel().getOffsetAt(e.selection.getStartPosition());
const endOffset = source.getModel().getOffsetAt(e.selection.getEndPosition());
remoteSelectionManager.setSelectionOffsets(sourceUser.id, startOffset, endOffset);
});
const sourceContentManager = new MonacoCollabExt.EditorContentManager({
editor: source,
onInsert(index, text) {
targetContentManager.insert(index, text);
},
onReplace(index, length, text) {
targetContentManager.replace(index, length, text);
},
onDelete(index, length) {
targetContentManager.delete(index, length);
}
});
});

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8">
<link rel="stylesheet" data-name="vs/editor/editor.main"
href="../node_modules/monaco-editor/min/vs/editor/editor.main.css"/>
<link rel="stylesheet" href="../dist/css/monaco-collab-ext.css">
<link rel="stylesheet" href="./example.css">
</head>
<body>
<div class="editors">
<div class="editor-column">
<div class="editor" id="source-editor"></div>
</div>
<div class="editor-column">
<div class="editor" id="target-editor"></div>
</div>
</div>
<script src="editor_contents.js"></script>
<script>var require = {paths: {'vs': '../node_modules/monaco-editor/min/vs'}};</script>
<script src="../node_modules/monaco-editor/min/vs/loader.js"></script>
<script src="../node_modules/monaco-editor/min/vs/editor/editor.main.nls.js"></script>
<script src="../node_modules/monaco-editor/min/vs/editor/editor.main.js"></script>
<script src="../dist/umd/monaco-collab-ext.js"></script>
<script src="./example.js"></script>
</body>
</html>

@ -0,0 +1,96 @@
import {src, dest, series} from "gulp";
import insert from "gulp-insert";
import webpackStream from "webpack-stream";
import webpack from "webpack";
import babel from "gulp-babel";
import rename from "gulp-rename";
import uglify from "gulp-uglify";
import sourcemaps from "gulp-sourcemaps";
import del from "del";
import cleanCSS from "gulp-clean-css";
import header from "gulp-header";
import trim from "trim";
import filter from 'gulp-filter-each';
import fs from "fs";
import gulpTypescript from "gulp-typescript";
import typescript from "typescript";
const tsProject = gulpTypescript.createProject("tsconfig.json", {
declaration: true,
typescript: typescript
});
const exportFilter = "export {};";
const copyFiles = () =>
src(["README.md", "LICENSE.txt", "package.json"])
.pipe(dest("dist"));
const umd = () => {
const outputPath = "dist/umd";
const packageJson = JSON.parse(fs.readFileSync("./package.json"));
const headerTxt = fs.readFileSync("./copyright-header.txt");
return src("./src/ts/index.ts")
.pipe(webpackStream(require("./webpack.config.js"), webpack))
.pipe(header(headerTxt, {package: packageJson}))
.pipe(dest(outputPath));
};
const minifyUmd = () =>
src("dist/umd/monaco-collab-ext.js")
.pipe(sourcemaps.init())
.pipe(uglify({
output: {
comments: "some"
}
}))
.pipe(rename({extname: ".min.js"}))
.pipe(sourcemaps.write("."))
.pipe(dest("dist/umd"));
const commonjs = () =>
src("src/ts/*.ts")
.pipe(babel())
.pipe(dest("dist/lib"));
const typings = () =>
src("src/ts/*.ts")
.pipe(tsProject())
.dts
.pipe(filter(content => trim(content) !== exportFilter))
.pipe(dest("dist/typings"));
const appendTypingsNamespace = () =>
src("dist/typings/index.d.ts", {base: './'})
.pipe(insert.append('\nexport as namespace MonacoCollabExt;\n'))
.pipe(dest("./"));
const css = () =>
src("src/css/*.css")
.pipe(dest("dist/css"));
const minifyCss = () =>
src(`dist/css/monaco-collab-ext.css`)
.pipe(sourcemaps.init())
.pipe(cleanCSS())
.pipe(rename({extname: ".min.css"}))
.pipe(sourcemaps.write("."))
.pipe(dest("dist/css"));
const clean = () => del(["dist"]);
const dist = series([
umd,
minifyUmd,
commonjs,
typings,
appendTypingsNamespace,
css,
minifyCss,
copyFiles]);
export {
dist,
clean
}

@ -0,0 +1,68 @@
{
"name": "@convergencelabs/monaco-collab-ext",
"version": "0.1.0",
"title": "Monaco Editor Collaborative Extensions",
"description": "Collaborative Extensions for the Monaco Editor",
"keywords": [
"collaboration",
"monaco",
"editor"
],
"homepage": "http://convergencelabs.com",
"author": {
"name": "Convergence Labs",
"email": "info@convergencelabs.com",
"url": "http://convergencelabs.com"
},
"contributors": [],
"repository": {
"type": "git",
"url": "https://github.com/convergencelabs/monaco-collab-ext.git"
},
"bugs": {
"url": "https://github.com/convergencelabs/monaco-collab-ext/issues"
},
"license": "MIT",
"main": "lib/index.js",
"typings": "typings/index.d.ts",
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
},
"dependencies": {
"monaco-editor": "^0.15.6"
},
"devDependencies": {
"@babel/cli": "7.2.3",
"@babel/core": "7.2.2",
"@babel/node": "7.2.2",
"@babel/preset-env": "7.2.3",
"@babel/preset-stage-3": "7.0.0",
"@babel/preset-typescript": "7.1.0",
"@babel/register": "7.0.0",
"babel-plugin-module-resolver": "3.1.1",
"babel-plugin-transform-class-properties": "6.24.1",
"del": "3.0.0",
"gulp": "4.0.0",
"gulp-babel": "8.0.0",
"gulp-bump": "3.1.1",
"gulp-clean-css": "4.0.0",
"gulp-filter-each": "1.0.1",
"gulp-header": "2.0.7",
"gulp-insert": "0.5.0",
"gulp-rename": "1.4.0",
"gulp-sourcemaps": "2.6.4",
"gulp-typescript": "5.0.0",
"gulp-uglify": "3.0.1",
"trim": "0.0.1",
"ts-loader": "5.3.3",
"tslint": "5.12.0",
"typescript": "3.3.3333",
"webpack": "4.28.1",
"webpack-stream": "5.2.1"
},
"scripts": {
"dist": "gulp dist",
"clean": "gulp clean"
}
}

@ -0,0 +1,44 @@
.monaco-remote-cursor {
position: absolute;
pointer-events: none;
z-index: 4000;
width: 2px;
}
.monaco-remote-cursor:before {
content: "";
width: 6px;
height: 5px;
display: block;
margin-left: -2px;
margin-top: 0;
z-index: 4000;
background: inherit;
}
.monaco-remote-cursor-tooltip {
position: absolute;
white-space: nowrap;
color: #FFFFFF;
text-shadow: 0 0 1px #000000;
opacity: 1.0;
font-size: 12px;
padding: 2px;
font-family: sans-serif;
z-index: 4000;
transition: opacity 0.5s ease-out;
-webkit-transition: opacity 0.5s ease-out;
-moz-transition: opacity 0.5s ease-out;
-ms-transition: opacity 0.5s ease-out;
-o-transition: opacity 0.5s ease-out;
}
.monaco-remote-selection {
position: absolute;
pointer-events: auto;
z-index: 10;
opacity: 0.3;
background: blue;
z-index: 4000;
}

@ -0,0 +1,237 @@
import * as monaco from "monaco-editor";
import {editor, IDisposable} from "monaco-editor";
import {Validation} from "./Validation";
/**
* The IEditorContentManagerOptions interface represents the set of options that
* configures how the EditorContentManager behaves.
*/
export interface IEditorContentManagerOptions {
/**
* The instance of the Monaco editor to add the remote cursors to.
*/
editor: monaco.editor.ICodeEditor;
/**
* Handles cases where text was inserted into the editor.
*
* @param index
* The zero-based offset where the text insert occurred.
* @param text
* the text that was inserted.
*/
onInsert?: (index: number, text: string) => void;
/**
* Handles cases where text was replaced in the editor.
*
* @param index
* The zero-based offset at the beginning of the replaced range.
* @param length
* The length of the range that was replaced.
* @param text
* the text that was inserted.
*/
onReplace?: (index: number, length: number, text: string) => void;
/**
* Handles cases where text was deleted from the editor.
*
* @param index
* The zero-based offset at the beginning of the removed range.
* @param length
* The length of the range that was removed.
*/
onDelete?: (index: number, length: number) => void;
/**
* The source id that will be used when making remote edits.
*/
remoteSourceId?: string;
}
/**
* The EditorContentManager facilitates listening to local content changes and
* the playback of remote content changes into the editor.
*/
export class EditorContentManager {
/**
* Option defaults.
*
* @internal
*/
private static readonly _DEFAULTS = {
onInsert: () => {
// no-op
},
onReplace: () => {
// no-op
},
onDelete: () => {
// no-op
},
remoteSourceId: "remote"
};
/**
* The options that configure the EditorContentManager.
* @internal
*/
private readonly _options: IEditorContentManagerOptions;
/**
* A flag denoting if outgoing events should be suppressed.
* @internal
*/
private _suppress: boolean;
/**
* A callback to dispose of the content change listener.
* @internal
*/
private _disposer: IDisposable;
/**
* Constructs a new EditorContentManager using the supplied options.
*
* @param options
* The options that configure the EditorContentManager.
*/
constructor(options: IEditorContentManagerOptions) {
this._options = {...EditorContentManager._DEFAULTS, ...options};
Validation.assertDefined(this._options, "options");
Validation.assertDefined(this._options.editor, "options.editor");
Validation.assertFunction(this._options.onInsert, "options.onInsert");
Validation.assertFunction(this._options.onReplace, "options.onReplace");
Validation.assertFunction(this._options.onDelete, "options.onDelete");
this._disposer = this._options.editor.onDidChangeModelContent(this._onContentChanged);
}
/**
* Inserts text into the editor.
*
* @param index
* The index to insert text at.
* @param text
* The text to insert.
*/
public insert(index: number, text: string): void {
this._suppress = true;
const {editor: ed, remoteSourceId} = this._options;
const position = ed.getModel().getPositionAt(index);
ed.executeEdits(remoteSourceId, [{
range: new monaco.Range(
position.lineNumber,
position.column,
position.lineNumber,
position.column
),
text,
forceMoveMarkers: true
}]);
this._suppress = false;
}
/**
* Replaces text in the editor.
*
* @param index
* The start index of the range to replace.
* @param length
* The length of the range to replace.
* @param text
* The text to insert.
*/
public replace(index: number, length: number, text: string): void {
this._suppress = true;
const {editor: ed, remoteSourceId} = this._options;
const start = ed.getModel().getPositionAt(index);
const end = ed.getModel().getPositionAt(index + length);
ed.executeEdits(remoteSourceId, [{
range: new monaco.Range(
start.lineNumber,
start.column,
end.lineNumber,
end.column
),
text,
forceMoveMarkers: true
}]);
this._suppress = false;
}
/**
* Deletes text in the editor.
*
* @param index
* The start index of the range to remove.
* @param length
* The length of the range to remove.
*/
public delete(index: number, length: number): void {
this._suppress = true;
const {editor: ed, remoteSourceId} = this._options;
const start = ed.getModel().getPositionAt(index);
const end = ed.getModel().getPositionAt(index + length);
ed.executeEdits(remoteSourceId, [{
range: new monaco.Range(
start.lineNumber,
start.column,
end.lineNumber,
end.column
),
text: "",
forceMoveMarkers: true
}]);
this._suppress = false;
}
/**
* Disposes of the content manager, freeing any resources.
*/
public dispose(): void {
this._disposer.dispose();
}
/**
* A helper method to process local changes from Monaco.
*
* @param e
* The event to process.
* @private
* @internal
*/
private _onContentChanged = (e: editor.IModelContentChangedEvent) => {
if (!this._suppress) {
e.changes.forEach((change: editor.IModelContentChange) => this._processChange(change));
}
}
/**
* A helper method to process a single content change.
*
* @param change
* The change to process.
* @private
* @internal
*/
private _processChange(change: editor.IModelContentChange): void {
Validation.assertDefined(change, "change");
const {rangeOffset, rangeLength, text} = change;
if (text.length > 0 && rangeLength === 0) {
this._options.onInsert(rangeOffset, text);
} else if (text.length > 0 && rangeLength > 0) {
this._options.onReplace(rangeOffset, rangeLength, text);
} else if (text.length === 0 && rangeLength > 0) {
this._options.onDelete(rangeOffset, rangeLength);
} else {
throw new Error("Unexpected change: " + JSON.stringify(change));
}
}
}

@ -0,0 +1,6 @@
/**
* A simple callback type that signifies a resource has been disposed.
*
* @internal
*/
export type OnDisposed = () => void;

@ -0,0 +1,98 @@
import {IPosition} from "monaco-editor";
import {RemoteCursorWidget} from "./RemoteCursorWidget";
/**
* The RemoteCursor class represents a remote cursor in the MonacoEditor. This
* class allows you to control the location and visibility of the cursor.
*/
export class RemoteCursor {
/**
* @internal
*/
private readonly _delegate: RemoteCursorWidget;
/**
* Creates a new RemoteCursor.
*
* @param delegate
* The underlying Monaco Editor widget.
* @internal
* @hidden
*/
constructor(delegate: RemoteCursorWidget) {
this._delegate = delegate;
}
/**
* Gets the unique id of this cursor.
*
* @returns
* The unique id of this cursor.
*/
public getId(): string {
return this._delegate.getId();
}
/**
* Gets the position of the cursor.
*
* @returns
* The position of the cursor.
*/
public getPosition(): IPosition {
return this._delegate.getPosition().position;
}
/**
* Sets the location of the cursor based on a Monaco Editor IPosition.
*
* @param position
* The line / column position of the cursor.
*/
public setPosition(position: IPosition): void {
this._delegate.setPosition(position);
}
/**
* Sets the location of the cursor using a zero-based text offset.
*
* @param offset
* The offset of the cursor.
*/
public setOffset(offset: number): void {
this._delegate.setOffset(offset);
}
/**
* Shows the cursor if it is hidden.
*/
public show(): void {
this._delegate.show();
}
/**
* Hides the cursor if it is shown.
*/
public hide(): void {
this._delegate.hide();
}
/**
* Determines if the cursor has already been disposed. A cursor is disposed
* when it has been permanently removed from the editor.
*
* @returns
* True if the cursor has been disposed, false otherwise.
*/
public isDisposed(): boolean {
return this._delegate.isDisposed();
}
/**
* Disposes of this cursor, removing it from the editor.
*/
public dispose(): void {
this._delegate.dispose();
}
}

@ -0,0 +1,205 @@
import * as monaco from "monaco-editor";
import {IPosition} from "monaco-editor";
import {RemoteCursor} from "./RemoteCursor";
import {RemoteCursorWidget} from "./RemoteCursorWidget";
import {Validation} from "./Validation";
/**
* The IRemoteCursorManagerOptions interface represents the set of options that
* configures how the RemoteCursorManager works.
*/
export interface IRemoteCursorManagerOptions {
/**
* The instance of the Monaco editor to add the remote cursors to.
*/
editor: monaco.editor.ICodeEditor;
/**
* Determines if tooltips will be shown when the cursor is moved.
*/
tooltips?: boolean;
/**
* The time (in seconds) that the tooltip should remain visible after
* it was last moved.
*/
tooltipDuration?: number;
}
/**
* The RemoteCursorManager class is responsible for creating and managing a
* set of indicators that show where remote users's cursors are located when
* using Monaco in a collaborative editing context. The RemoteCursorManager
* leverages Monaco's Content Widget concept.
*/
export class RemoteCursorManager {
/**
* The default values for optional parameters.
* @internal
*/
private static readonly DEFAULT_OPTIONS = {tooltips: true, tooltipDuration: 1};
/**
* A counter that generates unique ids for the cursor widgets.
* @internal
*/
private _nextWidgetId: number;
/**
* Tracks the current cursor widgets by the userland id.
* @internal
*/
private readonly _cursorWidgets: Map<string, RemoteCursorWidget>;
/**
* The options (and defaults) used to configure this instance.
* @internal
*/
private readonly _options: IRemoteCursorManagerOptions;
/**
* Creates a new RemoteCursorManager with the supplied options.
*
* @param options
* The options that will configure the RemoteCursorManager behavior.
*/
constructor(options: IRemoteCursorManagerOptions) {
if (typeof options !== "object") {
throw new Error("'options' is a required parameter and must be an object.");
}
// Override the defaults.
options = {...RemoteCursorManager.DEFAULT_OPTIONS, ...options};
if (options.editor === undefined || options.editor === null) {
throw new Error(`options.editor must be defined but was: ${options.editor}`);
}
this._options = options;
this._cursorWidgets = new Map<string, RemoteCursorWidget>();
this._nextWidgetId = 0;
}
/**
* Adds a new remote cursor to the editor.
*
* @param id
* A unique id that will be used to reference this cursor.
* @param color
* The css color that the cursor and tooltip should be rendered in.
* @param label
* An optional label for the tooltip. If tooltips are enabled.
*
* @returns
* The remote cursor widget that will be added to the editor.
*/
public addCursor(id: string, color: string, label?: string): RemoteCursor {
Validation.assertString(id, "id");
Validation.assertString(color, "color");
if (this._options.tooltips && typeof "label" !== "string") {
throw new Error("'label' is required when tooltips are enabled.");
}
const widgetId = "" + this._nextWidgetId++;
const tooltipDurationMs = this._options.tooltipDuration * 1000;
const cursorWidget = new RemoteCursorWidget(
this._options.editor,
widgetId,
color,
label,
this._options.tooltips,
tooltipDurationMs,
() => this.removeCursor(id));
this._cursorWidgets.set(id, cursorWidget);
return new RemoteCursor(cursorWidget);
}
/**
* Removes the remote cursor from the editor.
*
* @param id
* The unique id of the cursor to remove.
*/
public removeCursor(id: string): void {
Validation.assertString(id, "id");
const remoteCursorWidget = this._getCursor(id);
if (!remoteCursorWidget.isDisposed()) {
remoteCursorWidget.dispose();
}
this._cursorWidgets.delete(id);
}
/**
* Updates the location of the specified remote cursor using a Monaco
* IPosition object..
*
* @param id
* The unique id of the cursor to remove.
* @param position
* The location of the cursor to set.
*/
public setCursorPosition(id: string, position: IPosition) {
Validation.assertString(id, "id");
const remoteCursorWidget = this._getCursor(id);
remoteCursorWidget.setPosition(position);
}
/**
* Updates the location of the specified remote cursor based on a zero-based
* text offset.
*
* @param id
* The unique id of the cursor to remove.
* @param offset
* The location of the cursor to set.
*/
public setCursorOffset(id: string, offset: number) {
Validation.assertString(id, "id");
const remoteCursorWidget = this._getCursor(id);
remoteCursorWidget.setOffset(offset);
}
/**
* Shows the specified cursor. Note the cursor may be scrolled out of view.
*
* @param id
* The unique id of the cursor to show.
*/
public showCursor(id: string): void {
Validation.assertString(id, "id");
const remoteCursorWidget = this._getCursor(id);
remoteCursorWidget.show();
}
/**
* Hides the specified cursor.
*
* @param id
* The unique id of the cursor to show.
*/
public hideCursor(id: string): void {
Validation.assertString(id, "id");
const remoteCursorWidget = this._getCursor(id);
remoteCursorWidget.hide();
}
/**
* A helper method that gets a cursor by id, or throws an exception.
* @internal
*/
private _getCursor(id: string): RemoteCursorWidget {
if (!this._cursorWidgets.has(id)) {
throw new Error("No such cursor: " + id);
}
return this._cursorWidgets.get(id);
}
}

@ -0,0 +1,221 @@
import {editor, IDisposable, IPosition} from "monaco-editor";
import {EditorContentManager} from "./EditorContentManager";
import {OnDisposed} from "./OnDisposed";
import {Validation} from "./Validation";
/**
* This class implements a Monaco Content Widget to render a remote user's
* cursor, and an optional tooltip.
*
* @internal
*/
export class RemoteCursorWidget implements editor.IContentWidget, IDisposable {
private readonly _id: string;
private readonly _editor: editor.ICodeEditor;
private readonly _domNode: HTMLDivElement;
private readonly _tooltipNode: HTMLDivElement | null;
private readonly _tooltipDuration: number;
private readonly _scrollListener: IDisposable | null;
private readonly _onDisposed: OnDisposed;
private readonly _contentManager: EditorContentManager;
private _position: editor.IContentWidgetPosition | null;
private _offset: number;
private _hideTimer: any;
private _disposed: boolean;
constructor(codeEditor: editor.ICodeEditor,
widgetId: string,
color: string,
label: string,
tooltipEnabled: boolean,
tooltipDuration: number,
onDisposed: OnDisposed) {
this._editor = codeEditor;
this._tooltipDuration = tooltipDuration;
this._id = `monaco-remote-cursor-${widgetId}`;
this._onDisposed = onDisposed;
// Create the main node for the cursor element.
const {lineHeight} = this._editor.getConfiguration();
this._domNode = document.createElement("div");
this._domNode.className = "monaco-remote-cursor";
this._domNode.style.background = color;
this._domNode.style.height = `${lineHeight}px`;
// Create the tooltip element if the tooltip is enabled.
if (tooltipEnabled) {
this._tooltipNode = document.createElement("div");
this._tooltipNode.className = "monaco-remote-cursor-tooltip";
this._tooltipNode.style.background = color;
this._tooltipNode.innerHTML = label;
this._domNode.appendChild(this._tooltipNode);
// we only need to listen to scroll positions to update the
// tooltip location on scrolling.
this._scrollListener = this._editor.onDidScrollChange(() => {
this._updateTooltipPosition();
});
} else {
this._tooltipNode = null;
this._scrollListener = null;
}
this._contentManager = new EditorContentManager({
editor: this._editor,
onInsert: this._onInsert,
onReplace: this._onReplace,
onDelete: this._onDelete
});
this._hideTimer = null;
this._editor.addContentWidget(this);
this._offset = -1;
this._disposed = false;
}
public hide(): void {
this._domNode.style.display = "none";
}
public show(): void {
this._domNode.style.display = "inherit";
}
public setOffset(offset: number): void {
Validation.assertNumber(offset, "offset");
const position = this._editor.getModel().getPositionAt(offset);
this.setPosition(position);
}
public setPosition(position: IPosition): void {
Validation.assertPosition(position, "position");
this._updatePosition(position);
if (this._tooltipNode !== null) {
setTimeout(() => this._showTooltip(), 0);
}
}
public isDisposed(): boolean {
return this._disposed;
}
public dispose(): void {
if (this._disposed) {
return;
}
this._editor.removeContentWidget(this);
if (this._scrollListener !== null) {
this._scrollListener.dispose();
}
this._contentManager.dispose();
this._disposed = true;
this._onDisposed();
}
public getId(): string {
return this._id;
}
public getDomNode(): HTMLElement {
return this._domNode;
}
public getPosition(): editor.IContentWidgetPosition | null {
return this._position;
}
private _updatePosition(position: IPosition): void {
this._position = {
position: {...position},
preference: [editor.ContentWidgetPositionPreference.EXACT]
};
this._offset = this._editor.getModel().getOffsetAt(position);
this._editor.layoutContentWidget(this);
}
private _showTooltip(): void {
this._updateTooltipPosition();
if (this._hideTimer !== null) {
clearTimeout(this._hideTimer);
} else {
this._setTooltipVisible(true);
}
this._hideTimer = setTimeout(() => {
this._setTooltipVisible(false);
this._hideTimer = null;
}, this._tooltipDuration);
}
private _updateTooltipPosition(): void {
const distanceFromTop = this._domNode.offsetTop - this._editor.getScrollTop();
if (distanceFromTop - this._tooltipNode.offsetHeight < 5) {
this._tooltipNode.style.top = `${this._tooltipNode.offsetHeight + 2}px`;
} else {
this._tooltipNode.style.top = `-${this._tooltipNode.offsetHeight}px`;
}
this._tooltipNode.style.left = "0";
}
private _setTooltipVisible(visible: boolean): void {
if (visible) {
this._tooltipNode.style.opacity = "1.0";
} else {
this._tooltipNode.style.opacity = "0";
}
}
private _onInsert = (index: number, text: string) => {
if (this._position === null) {
return;
}
const offset = this._offset;
if (index <= offset) {
const newOffset = offset + text.length;
const position = this._editor.getModel().getPositionAt(newOffset);
this._updatePosition(position);
}
}
private _onReplace = (index: number, length: number, text: string) => {
if (this._position === null) {
return;
}
const offset = this._offset;
if (index <= offset) {
const newOffset = (offset - Math.min(offset - index, length)) + text.length;
const position = this._editor.getModel().getPositionAt(newOffset);
this._updatePosition(position);
}
}
private _onDelete = (index: number, length: number) => {
if (this._position === null) {
return;
}
const offset = this._offset;
if (index <= offset) {
const newOffset = offset - Math.min(offset - index, length);
const position = this._editor.getModel().getPositionAt(newOffset);
this._updatePosition(position);
}
}
}

@ -0,0 +1,254 @@
import * as monaco from "monaco-editor";
import {editor, IPosition} from "monaco-editor";
import {OnDisposed} from "./OnDisposed";
import {Validation} from "./Validation";
export class RemoteSelection {
/**
* A helper method to add a style tag to the head of the document that will
* style the color of the selection. The Monaco Editor only allows setting
* the class name of decorations, so we can not set a style property directly.
* This method will create, add, and return the style tag for this element.
*
* @param className
* The className to use as the css selector.
* @param color
* The color to set for the selection.
* @returns
* The style element that was added to the document head.
*
* @private
* @internal
*/
private static _addDynamicStyleElement(className: string, color: string): HTMLStyleElement {
Validation.assertString(className, "className");
Validation.assertString(color, "color");
const css =
`.${className} {
background-color: ${color};
}`.trim();
const styleElement = document.createElement("style");
styleElement.innerText = css;
document.head.appendChild(styleElement);
return styleElement;
}
/**
* A helper method to ensure the start position is before the end position.
*
* @param start
* The current start position.
* @param end
* The current end position.
* @return
* An object containing the correctly ordered start and end positions.
*
* @private
* @internal
*/
private static _swapIfNeeded(start: IPosition, end: IPosition): { start: IPosition, end: IPosition } {
if (start.lineNumber < end.lineNumber || (start.lineNumber === end.lineNumber && start.column <= end.column)) {
return {start, end};
} else {
return {start: end, end: start};
}
}
/**
* The userland id of the selection.
* @internal
*/
private readonly _id: string;
/**
* The css classname to apply to the Monaco decoration.
* @internal
*/
private readonly _className: string;
/**
* The HTML Style element added to the document to color the selection.
* @internal
*/
private readonly _styleElement: HTMLStyleElement;
/**
* The Monaco editor isntance to render selection into.
* @internal
*/
private readonly _editor: editor.ICodeEditor;
/**
* An internal callback used to dispose of the selection.
* @internal
*/
private readonly _onDisposed: OnDisposed;
/**
* The current start position of the selection.
* @internal
*/
private _startPosition: IPosition;
/**
* The current end position of the selection.
* @internal
*/
private _endPosition: IPosition;
/**
* The id's of the current Monaco decorations rendering the selection.
* @internal
*/
private _decorations: string[];
/**
* A flag determining if the selection has been disposed.
* @internal
*/
private _disposed: boolean;
/**
* Constructs a new remote selection.
*
* @internal
*/
constructor(
codeEditor: editor.ICodeEditor,
id: string,
classId: number,
color: string,
onDisposed: OnDisposed
) {
this._editor = codeEditor;
this._id = id;
const uniqueClassId = `monaco-remote-selection-${classId}`;
this._className = `monaco-remote-selection ${uniqueClassId}`;
this._styleElement = RemoteSelection._addDynamicStyleElement(uniqueClassId, color);
this._decorations = [];
this._onDisposed = onDisposed;
}
/**
* Gets the userland id of this selection.
*/
public getId(): string {
return this._id;
}
/**
* Gets the start position of the selection.
*
* @returns
* The start position of the selection.
*/
public getStartPosition(): IPosition {
return {...this._startPosition};
}
/**
* Gets the start position of the selection.
*
* @returns
* The start position of the selection.
*/
public getEndPosition(): IPosition {
return {...this._endPosition};
}
/**
* Sets the selection using zero-based text indices.
*
* @param start
* The start offset to set the selection to.
* @param end
* The end offset to set the selection to.
*/
public setOffsets(start: number, end: number): void {
const startPosition = this._editor.getModel().getPositionAt(start);
const endPosition = this._editor.getModel().getPositionAt(end);
this.setPositions(startPosition, endPosition);
}
/**
* Sets the selection using Monaco's line-number / column coordinate system.
*
* @param start
* The start position to set the selection to.
* @param end
* The end position to set the selection to.
*/
public setPositions(start: IPosition, end: IPosition): void {
// this._decorations = this._editor.deltaDecorations(this._decorations, []);
const ordered = RemoteSelection._swapIfNeeded(start, end);
this._startPosition = ordered.start;
this._endPosition = ordered.end;
this._render();
}
/**
* Makes the selection visible if it is hidden.
*/
public show(): void {
this._render();
}
/**
* Makes the selection hidden if it is visible.
*/
public hide(): void {
this._decorations = this._editor.deltaDecorations(this._decorations, []);
}
/**
* Determines if the selection has been permanently removed from the editor.
*
* @returns
* True if the selection has been disposed, false otherwise.
*/
public isDisposed(): boolean {
return this._disposed;
}
/**
* Permanently removes the selection from the editor.
*/
public dispose(): void {
if (!this._disposed) {
this._styleElement.parentElement.removeChild(this._styleElement);
this.hide();
this._onDisposed();
this._disposed = true;
}
}
/**
* A helper method that actually renders the selection as a decoration within
* the Monaco Editor.
*
* @private
* @internal
*/
private _render(): void {
this._decorations = this._editor.deltaDecorations(this._decorations,
[
{
range: new monaco.Range(
this._startPosition.lineNumber,
this._startPosition.column,
this._endPosition.lineNumber,
this._endPosition.column
),
options: {
className: this._className
}
}
]
);
}
}

@ -0,0 +1,152 @@
import * as monaco from "monaco-editor";
import {IPosition} from "monaco-editor";
import {RemoteSelection} from "./RemoteSelection";
import {Validation} from "./Validation";
/**
* The IRemoteSelectionManagerOptions represents the options that
* configure the behavior a the RemoteSelectionManager.
*/
export interface IRemoteSelectionManagerOptions {
/**
* The Monaco Editor instace to render the remote selections into.
*/
editor: monaco.editor.ICodeEditor;
}
/**
* The RemoteSelectionManager renders remote users selections into the Monaco
* editor using the editor's built-in decorators mechanism.
*/
export class RemoteSelectionManager {
/**
* A internal unique identifier for each selection.
*
* @internal
*/
private _nextClassId: number;
/**
* Tracks the current remote selections.
*
* @internal
*/
private readonly _remoteSelections: Map<string, RemoteSelection>;
/**
* The options configuring this instance.
*
* @internal
*/
private readonly _options: IRemoteSelectionManagerOptions;
/**
* Creates a new RemoteSelectionManager with the specified options.
*
* @param options
* Ths options that configure the RemoteSelectionManager.
*/
constructor(options: IRemoteSelectionManagerOptions) {
Validation.assertDefined(options, "options");
this._remoteSelections = new Map<string, RemoteSelection>();
this._options = options;
this._nextClassId = 0;
}
/**
* Adds a new remote selection with a unique id and the specified color.
*
* @param id
* The unique id of the selection.
* @param color
* The color to render the selection with.
*/
public addSelection(id: string, color: string): RemoteSelection {
const onDisposed = () => {
this.removeSelection(id);
};
const selection = new RemoteSelection(this._options.editor, id, this._nextClassId++, color, onDisposed);
this._remoteSelections.set(id, selection);
return selection;
}
/**
* Removes an existing remote selection from the editor.
*
* @param id
* The unique id of the selection.
*/
public removeSelection(id: string): void {
const remoteSelection = this._getSelection(id);
if (!remoteSelection.isDisposed()) {
remoteSelection.dispose();
}
}
/**
* Sets the selection using zero-based text offset locations.
*
* @param id
* The unique id of the selection.
* @param start
* The starting offset of the selection.
* @param end
* The ending offset of the selection.
*/
public setSelectionOffsets(id: string, start: number, end: number): void {
const remoteSelection = this._getSelection(id);
remoteSelection.setOffsets(start, end);
}
/**
* Sets the selection using the Monaco Editor's IPosition (line numbers and columns)
* location concept.
*
* @param id
* The unique id of the selection.
* @param start
* The starting position of the selection.
* @param end
* The ending position of the selection.
*/
public setSelectionPositions(id: string, start: IPosition, end: IPosition): void {
const remoteSelection = this._getSelection(id);
remoteSelection.setPositions(start, end);
}
/**
* Shows the specified selection, if it is currently hidden.
*
* @param id
* The unique id of the selection.
*/
public showSelection(id: string): void {
const remoteSelection = this._getSelection(id);
remoteSelection.show();
}
/**
* Hides the specified selection, if it is currently shown.
*
* @param id
* The unique id of the selection.
*/
public hideSelection(id: string): void {
const remoteSelection = this._getSelection(id);
remoteSelection.hide();
}
/**
* A helper method that gets a cursor by id, or throws an exception.
* @internal
*/
private _getSelection(id: string): RemoteSelection {
if (!this._remoteSelections.has(id)) {
throw new Error("No such selection: " + id);
}
return this._remoteSelections.get(id);
}
}

@ -0,0 +1,38 @@
/**
* A helper class to aid in input validation.
*
* @internal
*/
export class Validation {
public static assertString(val: any, name: string): void {
if (typeof val !== "string") {
throw new Error(`${name} must be a string but was: ${val}`);
}
}
public static assertNumber(val: any, name: string): void {
if (typeof val !== "number") {
throw new Error(`${name} must be a number but was: ${val}`);
}
}
public static assertDefined(val: any, name: string): void {
if (val === undefined || val === null) {
throw new Error(`${name} must be a defined but was: ${val}`);
}
}
public static assertFunction(val: any, name: string): void {
if (typeof val !== "function") {
throw new Error(`${name} must be a function but was: ${typeof val}`);
}
}
public static assertPosition(val: any, name: string): void {
Validation.assertDefined(val, name);
if (typeof val.lineNumber !== "number" || typeof val.column !== "number") {
throw new Error(`${name} must be an Object like {lineNumber: number, column: number}: ${JSON.stringify(val)}`);
}
}
}

@ -0,0 +1,3 @@
export * from "./RemoteCursorManager";
export * from "./RemoteSelectionManager";
export * from "./EditorContentManager";

@ -0,0 +1,15 @@
{
"compilerOptions": {
"allowJs": false,
"allowSyntheticDefaultImports": true,
"module": "commonjs",
"noUnusedLocals": true,
"noUnusedParameters": true,
"pretty": true,
"skipLibCheck": true,
"stripInternal": true,
"sourceMap": true,
"noImplicitAny": true,
"lib": ["dom", "es5", "es7"]
}
}

@ -0,0 +1,9 @@
{
"extends": "tslint:recommended",
"rules": {
"object-literal-sort-keys": false,
"trailing-comma": false,
"variable-name": false,
"no-console": false
}
}

@ -0,0 +1,35 @@
module.exports = {
mode: 'production',
optimization: {
minimize: false
},
entry: "./src/ts/index.ts",
output: {
path: __dirname + "dist/lib",
library: "MonacoCollabExt",
libraryTarget: "umd",
umdNamedDefine: true,
filename: "monaco-collab-ext.js"
},
module: {
rules: [
{
test: /\.ts$/,
use: 'ts-loader',
exclude: /node_modules/
}
]
},
resolve: {
extensions: [ ".tsx", ".ts", ".js" ],
},
plugins: [],
externals: {
"monaco-editor": {
amd: "vs/editor/editor.main",
commonjs: "monaco-editor",
commonjs2: "monaco-editor",
root: "monaco"
}
}
};
Loading…
Cancel
Save