mirror of
https://github.com/convergencelabs/monaco-collab-ext.git
synced 2026-03-02 03:49:21 +00:00
Initial commit.
This commit is contained in:
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";
|
||||
Reference in New Issue
Block a user