Initial commit.

This commit is contained in:
Michael MacFadden
2019-03-03 22:18:50 -06:00
commit c743f8df2a
28 changed files with 1868 additions and 0 deletions

View 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;
}

View 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
View 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
View 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();
}
}

View 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);
}
}

View 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
View 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
}
}
]
);
}
}

View 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
View 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
View File

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