commit c743f8df2ab0a2fe04c206d6a7ea9bbc19ab8436 Author: Michael MacFadden Date: Sun Mar 3 22:18:50 2019 -0600 Initial commit. diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..969db38 --- /dev/null +++ b/.babelrc @@ -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"] + }] + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fd83a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +node_modules +dist diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..de80e37 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,6 @@ +language: node_js + +node_js: + - "10.10" + +script: npm run dist \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e0fd40a --- /dev/null +++ b/CHANGELOG.md @@ -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. + + + diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..db6a3b0 --- /dev/null +++ b/LICENSE.txt @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4aaf981 --- /dev/null +++ b/README.md @@ -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(); +``` \ No newline at end of file diff --git a/copyright-header.txt b/copyright-header.txt new file mode 100644 index 0000000..b018cf7 --- /dev/null +++ b/copyright-header.txt @@ -0,0 +1,5 @@ +/**! +© 2019 Convergence Labs, Inc. +@version <%= package.version %> +@license MIT +*/ diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..76fcfe1 Binary files /dev/null and b/docs/demo.gif differ diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..181e805 --- /dev/null +++ b/example/README.md @@ -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. \ No newline at end of file diff --git a/example/editor_contents.js b/example/editor_contents.js new file mode 100644 index 0000000..50c3ee2 --- /dev/null +++ b/example/editor_contents.js @@ -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; +})();`; \ No newline at end of file diff --git a/example/example.css b/example/example.css new file mode 100644 index 0000000..e8fade4 --- /dev/null +++ b/example/example.css @@ -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; +} diff --git a/example/example.js b/example/example.js new file mode 100644 index 0000000..ecc53ba --- /dev/null +++ b/example/example.js @@ -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); + } + }); +}); diff --git a/example/index.html b/example/index.html new file mode 100644 index 0000000..6874710 --- /dev/null +++ b/example/index.html @@ -0,0 +1,29 @@ + + + + + + + + + + + +
+
+
+
+
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/gulpfile.babel.js b/gulpfile.babel.js new file mode 100644 index 0000000..616f07e --- /dev/null +++ b/gulpfile.babel.js @@ -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 +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..39b0513 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/css/monaco-collab-ext.css b/src/css/monaco-collab-ext.css new file mode 100644 index 0000000..71f147b --- /dev/null +++ b/src/css/monaco-collab-ext.css @@ -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; +} diff --git a/src/ts/EditorContentManager.ts b/src/ts/EditorContentManager.ts new file mode 100644 index 0000000..07b6a80 --- /dev/null +++ b/src/ts/EditorContentManager.ts @@ -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)); + } + } +} diff --git a/src/ts/OnDisposed.ts b/src/ts/OnDisposed.ts new file mode 100644 index 0000000..f053f1b --- /dev/null +++ b/src/ts/OnDisposed.ts @@ -0,0 +1,6 @@ +/** + * A simple callback type that signifies a resource has been disposed. + * + * @internal + */ +export type OnDisposed = () => void; diff --git a/src/ts/RemoteCursor.ts b/src/ts/RemoteCursor.ts new file mode 100644 index 0000000..4be2fe2 --- /dev/null +++ b/src/ts/RemoteCursor.ts @@ -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(); + } +} diff --git a/src/ts/RemoteCursorManager.ts b/src/ts/RemoteCursorManager.ts new file mode 100644 index 0000000..663fc85 --- /dev/null +++ b/src/ts/RemoteCursorManager.ts @@ -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; + + /** + * 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(); + 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); + } +} diff --git a/src/ts/RemoteCursorWidget.ts b/src/ts/RemoteCursorWidget.ts new file mode 100644 index 0000000..e5a8472 --- /dev/null +++ b/src/ts/RemoteCursorWidget.ts @@ -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); + } + } +} diff --git a/src/ts/RemoteSelection.ts b/src/ts/RemoteSelection.ts new file mode 100644 index 0000000..0b1e6f6 --- /dev/null +++ b/src/ts/RemoteSelection.ts @@ -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 + } + } + ] + ); + } +} diff --git a/src/ts/RemoteSelectionManager.ts b/src/ts/RemoteSelectionManager.ts new file mode 100644 index 0000000..b13d673 --- /dev/null +++ b/src/ts/RemoteSelectionManager.ts @@ -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; + + /** + * 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(); + 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); + } +} diff --git a/src/ts/Validation.ts b/src/ts/Validation.ts new file mode 100644 index 0000000..9f7af8f --- /dev/null +++ b/src/ts/Validation.ts @@ -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)}`); + } + } +} diff --git a/src/ts/index.ts b/src/ts/index.ts new file mode 100644 index 0000000..0bb7b92 --- /dev/null +++ b/src/ts/index.ts @@ -0,0 +1,3 @@ +export * from "./RemoteCursorManager"; +export * from "./RemoteSelectionManager"; +export * from "./EditorContentManager"; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..01b9d3c --- /dev/null +++ b/tsconfig.json @@ -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"] + } +} \ No newline at end of file diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..9db5e8a --- /dev/null +++ b/tslint.json @@ -0,0 +1,9 @@ +{ + "extends": "tslint:recommended", + "rules": { + "object-literal-sort-keys": false, + "trailing-comma": false, + "variable-name": false, + "no-console": false + } +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..66ccf1d --- /dev/null +++ b/webpack.config.js @@ -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" + } + } +};