mirror of
https://github.com/convergencelabs/monaco-collab-ext.git
synced 2024-10-27 20:34:17 +00:00
Initial commit.
This commit is contained in:
commit
c743f8df2a
17
.babelrc
Normal file
17
.babelrc
Normal file
@ -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
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.idea
|
||||
node_modules
|
||||
dist
|
6
.travis.yml
Normal file
6
.travis.yml
Normal file
@ -0,0 +1,6 @@
|
||||
language: node_js
|
||||
|
||||
node_js:
|
||||
- "10.10"
|
||||
|
||||
script: npm run dist
|
8
CHANGELOG.md
Normal file
8
CHANGELOG.md
Normal file
@ -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.
|
||||
|
||||
|
||||
|
18
LICENSE.txt
Normal file
18
LICENSE.txt
Normal file
@ -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.
|
116
README.md
Normal file
116
README.md
Normal file
@ -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();
|
||||
```
|
5
copyright-header.txt
Normal file
5
copyright-header.txt
Normal file
@ -0,0 +1,5 @@
|
||||
/**!
|
||||
© 2019 Convergence Labs, Inc.
|
||||
@version <%= package.version %>
|
||||
@license MIT
|
||||
*/
|
BIN
docs/demo.gif
Normal file
BIN
docs/demo.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 602 KiB |
2
example/README.md
Normal file
2
example/README.md
Normal file
@ -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.
|
84
example/editor_contents.js
Normal file
84
example/editor_contents.js
Normal file
@ -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;
|
||||
})();`;
|
19
example/example.css
Normal file
19
example/example.css
Normal file
@ -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;
|
||||
}
|
80
example/example.js
Normal file
80
example/example.js
Normal file
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
29
example/index.html
Normal file
29
example/index.html
Normal file
@ -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>
|
96
gulpfile.babel.js
Normal file
96
gulpfile.babel.js
Normal file
@ -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
|
||||
}
|
68
package.json
Normal file
68
package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
44
src/css/monaco-collab-ext.css
Normal file
44
src/css/monaco-collab-ext.css
Normal file
@ -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;
|
||||
}
|
237
src/ts/EditorContentManager.ts
Normal file
237
src/ts/EditorContentManager.ts
Normal file
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
6
src/ts/OnDisposed.ts
Normal file
6
src/ts/OnDisposed.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* A simple callback type that signifies a resource has been disposed.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export type OnDisposed = () => void;
|
98
src/ts/RemoteCursor.ts
Normal file
98
src/ts/RemoteCursor.ts
Normal file
@ -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();
|
||||
}
|
||||
}
|
205
src/ts/RemoteCursorManager.ts
Normal file
205
src/ts/RemoteCursorManager.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
221
src/ts/RemoteCursorWidget.ts
Normal file
221
src/ts/RemoteCursorWidget.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
254
src/ts/RemoteSelection.ts
Normal file
254
src/ts/RemoteSelection.ts
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
152
src/ts/RemoteSelectionManager.ts
Normal file
152
src/ts/RemoteSelectionManager.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
38
src/ts/Validation.ts
Normal file
38
src/ts/Validation.ts
Normal file
@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
3
src/ts/index.ts
Normal file
3
src/ts/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./RemoteCursorManager";
|
||||
export * from "./RemoteSelectionManager";
|
||||
export * from "./EditorContentManager";
|
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@ -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"]
|
||||
}
|
||||
}
|
9
tslint.json
Normal file
9
tslint.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "tslint:recommended",
|
||||
"rules": {
|
||||
"object-literal-sort-keys": false,
|
||||
"trailing-comma": false,
|
||||
"variable-name": false,
|
||||
"no-console": false
|
||||
}
|
||||
}
|
35
webpack.config.js
Normal file
35
webpack.config.js
Normal file
@ -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…
Reference in New Issue
Block a user