├── .gitignore
├── src
├── ts
│ ├── RowRange.ts
│ ├── IndexRange.ts
│ ├── index.ts
│ ├── AceViewportUtil.ts
│ ├── AceRangeUtil.ts
│ ├── AceMultiSelectionManager.ts
│ ├── AceMultiCursorManager.ts
│ ├── AceRadarView.ts
│ ├── AceRadarViewIndicator.ts
│ ├── AceSelectionMarker.ts
│ └── AceCursorMarker.ts
└── css
│ └── ace-collab-ext.css
├── copyright-header.txt
├── scripts
├── enhance-types.js
└── build-css.js
├── tslint.json
├── tsconfig.json
├── .github
└── workflows
│ └── build.yml
├── example
├── example.css
├── index.html
├── example.js
└── editor_contents.js
├── LICENSE
├── rollup.config.js
├── package.json
├── CHANGELOG.md
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | node_modules
3 | dist
4 |
--------------------------------------------------------------------------------
/src/ts/RowRange.ts:
--------------------------------------------------------------------------------
1 | export interface IRowRange {
2 | start: number;
3 | end: number;
4 | }
5 |
--------------------------------------------------------------------------------
/copyright-header.txt:
--------------------------------------------------------------------------------
1 | © 2016-2021 Convergence Labs, Inc.
2 | @version <%= pkg.version %>
3 | @license MIT
--------------------------------------------------------------------------------
/src/ts/IndexRange.ts:
--------------------------------------------------------------------------------
1 | export interface IIndexRange {
2 | start: number;
3 | end: number;
4 | }
5 |
--------------------------------------------------------------------------------
/scripts/enhance-types.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 |
3 | fs.appendFileSync('dist/types/index.d.ts', '\nexport as namespace AceCollabExt;\n');
4 |
5 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "tslint:recommended",
3 | "rules": {
4 | "object-literal-sort-keys": false,
5 | "trailing-comma": false,
6 | "variable-name": false
7 | }
8 | }
--------------------------------------------------------------------------------
/src/ts/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./AceMultiSelectionManager";
2 | export * from "./AceMultiCursorManager";
3 | export * from "./AceRangeUtil";
4 | export * from "./AceRadarView";
5 | export * from "./AceViewportUtil";
6 |
--------------------------------------------------------------------------------
/scripts/build-css.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs-extra');
2 | const CleanCSS = require('clean-css');
3 |
4 | fs.copySync('src/css', 'dist/css');
5 |
6 | const css = fs.readFileSync('dist/css/ace-collab-ext.css');
7 | const options = {
8 | sourceMap: true,
9 | sourceMapInlineSources: true
10 | }
11 | const minified = new CleanCSS(options).minify(css);
12 | fs.writeFileSync( 'dist/css/ace-collab-ext.min.css', minified.styles );
13 | fs.writeFileSync( 'dist/css/ace-collab-ext.css.map', minified.sourceMap.toString() );
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "ES2020",
5 | "outDir": "dist",
6 | "preserveConstEnums": true,
7 | "moduleResolution": "node",
8 | "experimentalDecorators": true,
9 | "declaration": false,
10 | "esModuleInterop": true,
11 | "sourceMap": true,
12 | "allowSyntheticDefaultImports": true,
13 | "inlineSources": true,
14 | "baseUrl": "./src/ts",
15 | "lib": ["dom", "es6", "es7"]
16 | },
17 | "include": [
18 | "src/**/*"
19 | ],
20 | "exclude": [
21 | "build",
22 | "dist",
23 | "example"
24 | ]
25 | }
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
3 |
4 | name: Build
5 |
6 | on:
7 | push:
8 | branches: [ master ]
9 | pull_request:
10 | branches: [ master ]
11 |
12 | jobs:
13 | build:
14 |
15 | runs-on: ubuntu-latest
16 |
17 | strategy:
18 | matrix:
19 | node-version: [14.x]
20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
21 |
22 | steps:
23 | - uses: actions/checkout@v2
24 | - name: Use Node.js ${{ matrix.node-version }}
25 | uses: actions/setup-node@v2
26 | with:
27 | node-version: ${{ matrix.node-version }}
28 | cache: 'npm'
29 | - run: npm ci
30 | - run: npm run dist
31 |
--------------------------------------------------------------------------------
/example/example.css:
--------------------------------------------------------------------------------
1 | .body {
2 | margin: 0;
3 | }
4 |
5 | .main-container {
6 | position: absolute;
7 | top: 0;
8 | bottom: 0;
9 | left: 0;
10 | right: 0;
11 | display: flex;
12 | flex-direction: column;
13 | padding: 5px;
14 | }
15 |
16 | .title {
17 | font-family: "Helvetica Neue", sans-serif;
18 | font-size: 18px;
19 | font-weight: bold;
20 | margin-bottom: 10px;
21 | }
22 |
23 | .editors {
24 | display: flex;
25 | flex-direction: row;
26 | flex: 1;
27 | }
28 |
29 | .editor-column {
30 | display: flex;
31 | flex-direction: column;
32 | flex: 1;
33 | }
34 |
35 | .editor-label {
36 | text-align: center;
37 | font-family: "Helvetica Neue", sans-serif;
38 | font-size: 14px;
39 | font-weight: bold;
40 | margin-bottom: 10px;
41 | }
42 |
43 | .editor {
44 | display: inline-block;
45 | margin-left: 10px;
46 | flex: 1;
47 | }
48 |
49 | .wrapped-editor {
50 | flex: 1;
51 | display: flex;
52 | }
53 |
54 | #target-radar-view {
55 | display: inline-block;
56 | min-width: 20px;
57 | background: #2F3129;
58 | }
59 |
60 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2016 Convergence Labs, Inc.
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of
4 | this software and associated documentation files (the "Software"), to deal in
5 | the Software without restriction, including without limitation the rights to
6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7 | of the Software, and to permit persons to whom the Software is furnished to do
8 | so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
17 | AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH
18 | THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
19 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | ACE Collaborative Demo
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
Ace Collaborative Example
17 |
18 |
19 |
Source Editor
20 |
21 |
22 |
23 |
Target Editor
24 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/ts/AceViewportUtil.ts:
--------------------------------------------------------------------------------
1 | import {Ace} from "ace-builds";
2 | import {IIndexRange} from "./IndexRange";
3 | import {IRowRange} from "./RowRange";
4 |
5 | export class AceViewportUtil {
6 |
7 | public static getVisibleIndexRange(editor: Ace.Editor): IIndexRange {
8 | let firstRow: number = editor.getFirstVisibleRow();
9 | let lastRow: number = editor.getLastVisibleRow();
10 |
11 | if (!editor.isRowFullyVisible(firstRow)) {
12 | firstRow++;
13 | }
14 |
15 | if (!editor.isRowFullyVisible(lastRow)) {
16 | lastRow--;
17 | }
18 |
19 | const startPos: number = editor.getSession().getDocument().positionToIndex({row: firstRow, column: 0}, 0);
20 |
21 | // todo, this should probably be the end of the row
22 | const endPos: number = editor.getSession().getDocument().positionToIndex({row: lastRow, column: 0}, 0);
23 |
24 | return {
25 | start: startPos,
26 | end: endPos
27 | };
28 | }
29 |
30 | public static indicesToRows(editor: Ace.Editor, startIndex: number, endIndex: number): IRowRange {
31 | const startRow: number = editor.getSession().getDocument().indexToPosition(startIndex, 0).row;
32 | const endRow: number = editor.getSession().getDocument().indexToPosition(endIndex, 0).row;
33 |
34 | return {
35 | start: startRow,
36 | end: endRow
37 | };
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | // rollup.config.js
2 | import typescript from '@rollup/plugin-typescript';
3 | import resolve from '@rollup/plugin-node-resolve';
4 | import commonjs from '@rollup/plugin-commonjs';
5 | import license from "rollup-plugin-license";
6 | import {terser} from "rollup-plugin-terser";
7 | import path from "path";
8 |
9 | import pkg from './package.json';
10 |
11 | //
12 | // Commons Settings
13 | //
14 | const input = 'src/ts/index.ts';
15 |
16 | const plugins = [
17 | resolve(),
18 | commonjs(),
19 | typescript(),
20 | license({
21 | banner: {
22 | commentStyle: 'ignored', // The default
23 | content: {
24 | file: path.join(__dirname, 'copyright-header.txt'),
25 | },
26 | }
27 | })
28 | ];
29 |
30 | const moduleName = "AceCollabExt";
31 | const format = "umd";
32 |
33 | const external = [
34 | "ace-builds"
35 | ]
36 |
37 | const globals = {
38 | "ace-builds": "ace"
39 | }
40 |
41 | export default [{
42 | input,
43 | plugins,
44 | external,
45 | output: [
46 | {
47 | name: moduleName,
48 | file: pkg.browser,
49 | format,
50 | sourcemap: true,
51 | globals
52 | }
53 | ]
54 | }, {
55 | input,
56 | plugins: [...plugins, terser()],
57 | external,
58 | output: [
59 | {
60 | name: moduleName,
61 | file: `${path.dirname(pkg.browser)}/${path.basename(pkg.browser, ".js")}.min.js`,
62 | format,
63 | sourcemap: true,
64 | globals
65 | }
66 | ]
67 | }];
--------------------------------------------------------------------------------
/src/css/ace-collab-ext.css:
--------------------------------------------------------------------------------
1 | .ace-multi-cursor {
2 | position: absolute;
3 | pointer-events: auto;
4 | z-index: 10;
5 | }
6 |
7 | .ace-multi-cursor:before {
8 | content: "";
9 | width: 6px;
10 | height: 5px;
11 | display: block;
12 | background: inherit;
13 | margin-left: -2px;
14 | margin-top: -5px;
15 | }
16 |
17 | .ace-multi-cursor-tooltip {
18 | position: absolute;
19 | white-space: nowrap;
20 | color: #FFFFFF;
21 | text-shadow: 0 0 1px #000000;
22 | opacity: 1.0;
23 | font-size: 12px;
24 | padding: 2px;
25 | font-family: sans-serif;
26 |
27 | transition: opacity 0.5s ease-out;
28 | -webkit-transition: opacity 0.5s ease-out;
29 | -moz-transition: opacity 0.5s ease-out;
30 | -ms-transition: opacity 0.5s ease-out;
31 | -o-transition: opacity 0.5s ease-out;
32 | }
33 |
34 | .ace-multi-selection {
35 | position: absolute;
36 | pointer-events: auto;
37 | z-index: 10;
38 | opacity: 0.3;
39 | }
40 |
41 | .ace-radar-view {
42 | position: relative;
43 | min-width: 6px;
44 | }
45 |
46 | .ace-radar-view-scroll-indicator {
47 | position: absolute;
48 | left: 0;
49 | right: 0;
50 | border-radius: 4px;
51 | cursor: pointer;
52 | border-style: double;
53 | border-width: 3px;
54 | }
55 |
56 | .ace-radar-view-cursor-indicator {
57 | position: absolute;
58 | left: 0;
59 | right: 0;
60 | height: 4px;
61 | border-radius: 3px;
62 | cursor: pointer;
63 | border: 1px solid black;
64 | }
65 |
66 | .ace-radar-view-wrapper {
67 | position: relative;
68 | float: left;
69 |
70 | height: 100%;
71 | width: 6px;
72 |
73 | margin-right: 4px;
74 | }
75 |
--------------------------------------------------------------------------------
/src/ts/AceRangeUtil.ts:
--------------------------------------------------------------------------------
1 | import {Ace, Range} from "ace-builds";
2 |
3 | export interface IRangeData {
4 | start: {row: number, column: number};
5 | end: {row: number, column: number};
6 | }
7 |
8 | /**
9 | * A helper class for working with Ace Ranges.
10 | */
11 | export class AceRangeUtil {
12 |
13 | public static rangeToJson(range: Ace.Range): IRangeData {
14 | return {
15 | start: {
16 | row: range.start.row,
17 | column: range.start.column
18 | },
19 | end: {
20 | row: range.end.row,
21 | column: range.end.column
22 | }
23 | };
24 | }
25 |
26 | public static jsonToRange(range: IRangeData): Ace.Range {
27 | return new Range(
28 | range.start.row,
29 | range.start.column,
30 | range.end.row,
31 | range.end.column);
32 | }
33 |
34 | public static rangesToJson(ranges: Ace.Range[]): IRangeData[] {
35 | return ranges.map((range) => {
36 | return AceRangeUtil.rangeToJson(range);
37 | });
38 | }
39 |
40 | public static jsonToRanges(ranges: IRangeData[]): Ace.Range[] {
41 | return ranges.map((range) => {
42 | return AceRangeUtil.jsonToRange(range);
43 | });
44 | }
45 |
46 | public static toJson(value: Ace.Range): IRangeData;
47 | public static toJson(value: Ace.Range[]): IRangeData[];
48 | public static toJson(value: Ace.Range | Ace.Range[]) {
49 | if (Array.isArray(value)) {
50 | return AceRangeUtil.rangesToJson(value);
51 | }
52 |
53 | return AceRangeUtil.rangeToJson(value);
54 | }
55 |
56 | public static fromJson(value: IRangeData): Ace.Range;
57 | public static fromJson(value: IRangeData[]): Ace.Range[];
58 | public static fromJson(value: IRangeData | IRangeData[]): Ace.Range | Ace.Range[] {
59 | if (Array.isArray(value)) {
60 | return AceRangeUtil.jsonToRanges(value);
61 | }
62 |
63 | return AceRangeUtil.jsonToRange(value);
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@convergencelabs/ace-collab-ext",
3 | "version": "0.6.0",
4 | "title": "Ace Editor Collaborative Extensions",
5 | "description": "Collaborative Extensions for the Ace Editor",
6 | "keywords": [
7 | "collaboration",
8 | "ace",
9 | "editor"
10 | ],
11 | "homepage": "http://convergencelabs.com",
12 | "author": {
13 | "name": "Convergence Labs",
14 | "email": "info@convergencelabs.com",
15 | "url": "http://convergencelabs.com"
16 | },
17 | "contributors": [],
18 | "repository": {
19 | "type": "git",
20 | "url": "https://github.com/convergencelabs/ace-collab-ext.git"
21 | },
22 | "bugs": {
23 | "url": "https://github.com/convergencelabs/ace-collab-ext/issues"
24 | },
25 | "license": "MIT",
26 | "scripts": {
27 | "build:esm": "tsc --module ES2020 --target ES2020 --outDir dist/module",
28 | "build:commonjs": "tsc --module commonjs --target es5 --outDir dist/lib",
29 | "build:types": "tsc --declaration true --emitDeclarationOnly true --outDir dist/types && node ./scripts/enhance-types.js",
30 | "build:umd": "rollup -c rollup.config.js",
31 | "build:css": "node scripts/build-css.js",
32 | "dist": "npm run build:esm && npm run build:commonjs && npm run build:umd && npm run build:types && npm run build:css",
33 | "clean": "rimraf dist",
34 | "prepack": "npm run dist"
35 | },
36 | "publishConfig": {
37 | "registry": "https://registry.npmjs.org/",
38 | "access": "public"
39 | },
40 | "main": "dist/lib/index.js",
41 | "module": "dist/module/index.js",
42 | "types": "dist/types/index.d.ts",
43 | "browser": "dist/umd/ace-collab-ext.js",
44 | "files": [
45 | "dist",
46 | "example"
47 | ],
48 | "dependencies": {
49 | "ace-builds": "^1.4.12"
50 | },
51 | "devDependencies": {
52 | "@rollup/plugin-commonjs": "19.0.0",
53 | "@rollup/plugin-node-resolve": "13.0.0",
54 | "@rollup/plugin-typescript": "8.2.1",
55 | "@types/backbone": "1.4.1",
56 | "clean-css": "^5.1.3",
57 | "fs-extra": "^10.0.0",
58 | "rimraf": "^3.0.2",
59 | "rollup": "2.47.0",
60 | "rollup-plugin-license": "2.3.0",
61 | "rollup-plugin-terser": "7.0.2",
62 | "tslib": "^2.3.0",
63 | "typescript": "4.2.4"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/example/example.js:
--------------------------------------------------------------------------------
1 | const sourceUser = {
2 | id: "source",
3 | label: "Source User",
4 | color: "orange"
5 | };
6 |
7 | const sourceEditor = initEditor("source-editor");
8 | const sourceSession = sourceEditor.getSession();
9 |
10 | const targetEditor = initEditor("target-editor");
11 | targetEditor.setReadOnly(true);
12 |
13 | const targetCursorManager = new AceCollabExt.AceMultiCursorManager(targetEditor.getSession());
14 | targetCursorManager.addCursor(sourceUser.id, sourceUser.label, sourceUser.color, 0);
15 |
16 | const targetSelectionManager = new AceCollabExt.AceMultiSelectionManager(targetEditor.getSession());
17 | targetSelectionManager.addSelection(sourceUser.id, sourceUser.label, sourceUser.color, []);
18 |
19 | const radarView = new AceCollabExt.AceRadarView("target-radar-view", targetEditor);
20 |
21 |
22 | setTimeout(function() {
23 | radarView.addView("fake1", "fake1", "RoyalBlue", {start: 60, end: 75}, 50);
24 | radarView.addView("fake2", "fake2", "lightgreen", {start: 10, end: 50}, 30);
25 |
26 | const initialIndices = AceCollabExt.AceViewportUtil.getVisibleIndexRange(sourceEditor);
27 | const initialRows = AceCollabExt.AceViewportUtil.indicesToRows(sourceEditor, initialIndices.start, initialIndices.end);
28 | radarView.addView(sourceUser.id, sourceUser.label, sourceUser.color, initialRows, 0);
29 | }, 0);
30 |
31 | sourceSession.getDocument().on("change", function(e) {
32 | targetEditor.getSession().getDocument().applyDeltas([e]);
33 | });
34 |
35 | sourceSession.on("changeScrollTop", function (scrollTop) {
36 | setTimeout(function () {
37 | const viewportIndices = AceCollabExt.AceViewportUtil.getVisibleIndexRange(sourceEditor);
38 | const rows = AceCollabExt.AceViewportUtil.indicesToRows(sourceEditor, viewportIndices.start, viewportIndices.end);
39 | radarView.setViewRows(sourceUser.id, rows);
40 | }, 0);
41 | });
42 |
43 | sourceSession.selection.on('changeCursor', function(e) {
44 | const cursor = sourceEditor.getCursorPosition();
45 | targetCursorManager.setCursor(sourceUser.id, cursor);
46 | radarView.setCursorRow(sourceUser.id, cursor.row);
47 | });
48 |
49 | sourceSession.selection.on('changeSelection', function(e) {
50 | const rangesJson = AceCollabExt.AceRangeUtil.toJson(sourceEditor.selection.getAllRanges());
51 | const ranges = AceCollabExt.AceRangeUtil.fromJson(rangesJson);
52 | targetSelectionManager.setSelection(sourceUser.id, ranges);
53 | });
54 |
55 | function initEditor(id) {
56 | const editor = ace.edit(id);
57 | editor.setTheme('ace/theme/monokai');
58 |
59 | const session = editor.getSession();
60 | session.setMode('ace/mode/javascript');
61 | session.setValue(editorContents);
62 |
63 | return editor;
64 | }
65 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | ## [v0.6.0](https://github.com/convergencelabs/ace-collab-ext/tree/0.6.0) (2021-07-10)
4 |
5 | **Enhancements:**
6 | - Moved from webpack to rollup.
7 | - Removed gulp in favor of a straight node build.
8 | - Migrated from Travis CI to GitHub Actions.
9 |
10 |
11 | ## [v0.5.0](https://github.com/convergencelabs/ace-collab-ext/tree/0.5.0) (2020-03-14)
12 |
13 | **Enhancements:**
14 | - Moves from the `@convergence` to `@convergencelabs` scope.
15 |
16 | **Fixes:**
17 | - [\#15](https://github.com/convergencelabs/ace-collab-ext/issues/15) Fixed bad module import in the AceRadarView.
18 |
19 | ## [v0.4.0](https://github.com/convergencelabs/ace-collab-ext/tree/0.4.0) (2019-03-15)
20 |
21 | **Enhancements:**
22 | - [\#7](https://github.com/convergencelabs/ace-collab-ext/issues/7) Migrate from `brace` to `ace-builds`.
23 |
24 |
25 | ## [v0.3.0](https://github.com/convergencelabs/ace-collab-ext/tree/0.3.0) (2019-01-23)
26 |
27 | **Enhancements:**
28 | - [\#5](https://github.com/convergencelabs/ace-collab-ext/issues/5) Add cursor user tooltips.
29 | - [\#6](https://github.com/convergencelabs/ace-collab-ext/issues/6) Update to work with Ace > 1.4.0.
30 |
31 |
32 | ## [v0.2.3](https://github.com/convergencelabs/ace-collab-ext/tree/0.2.3) (2018-11-13)
33 |
34 | **Fixes:**
35 |
36 | - [\#4](https://github.com/convergencelabs/ace-collab-ext/pull/4) Improved selection when line wrapping is enabled.
37 |
38 |
39 | ## [v0.2.2](https://github.com/convergencelabs/ace-collab-ext/tree/0.2.2) (2018-05-01)
40 |
41 | **Enhancements:**
42 |
43 | - Added the RadarView.clearView method.
44 |
45 |
46 | ## [v0.2.1](https://github.com/convergencelabs/ace-collab-ext/tree/0.2.1) (2018-05-01)
47 |
48 | **Enhancements:**
49 |
50 | - Improved typings to better support UMD imports.
51 | - Updated the Multi Cursor Manager to accept Positions in addition to Indices
52 |
53 |
54 | ## [v0.2.0](https://github.com/convergencelabs/ace-collab-ext/tree/0.2.0) (2018-05-01)
55 |
56 | **Enhancements:**
57 |
58 | - [\#2](https://github.com/convergencelabs/ace-collab-ext/issues/2) The library now contains typescript definitions.
59 | - [\#3](https://github.com/convergencelabs/ace-collab-ext/issues/3) The common js build is no longer webpacked.
60 |
61 |
62 | ## [v0.1.1](https://github.com/convergencelabs/ace-collab-ext/tree/0.1.1) (2017-01-10)
63 |
64 | **Enhancements:**
65 |
66 | - [\#1](https://github.com/convergencelabs/ace-collab-ext/issues/1) The AceMultiCursorManager and AceMultiSelectionManager constructors now take an Ace EditSession instance instead of the Ace Editor instance so they can be initialized with a session before the editor is constructed.
67 |
68 |
69 | ## [v0.1.0](https://github.com/convergencelabs/ace-collab-ext/tree/0.1.0) (2016-12-27)
70 |
71 | - Initial release.
72 |
73 |
74 |
75 |
--------------------------------------------------------------------------------
/src/ts/AceMultiSelectionManager.ts:
--------------------------------------------------------------------------------
1 | import {Ace} from "ace-builds";
2 | import {AceSelectionMarker} from "./AceSelectionMarker";
3 |
4 | /**
5 | * Implements multiple colored selections in the ace editor. Each selection is
6 | * associated with a particular user. Each user is identified by a unique id
7 | * and has a color associated with them. The selection manager supports block
8 | * selection through multiple AceRanges.
9 | */
10 | export class AceMultiSelectionManager {
11 |
12 | private readonly _selections: { [key: string]: AceSelectionMarker };
13 | private readonly _session: Ace.EditSession;
14 |
15 | /**
16 | * Constructs a new AceMultiSelectionManager that is bound to a particular
17 | * Ace EditSession instance.
18 | *
19 | * @param session
20 | * The Ace EditSession to bind to.
21 | */
22 | constructor(session: Ace.EditSession) {
23 | this._selections = {};
24 | this._session = session;
25 | }
26 |
27 | /**
28 | * Adds a new collaborative selection.
29 | *
30 | * @param id
31 | * The unique system identifier for the user associated with this selection.
32 | * @param label
33 | * A human readable / meaningful label / title that identifies the user.
34 | * @param color
35 | * A valid css color string.
36 | * @param ranges
37 | * An array of ace ranges that specify the initial selection.
38 | */
39 | public addSelection(id: string, label: string, color: string, ranges: Ace.Range[]): void {
40 | if (this._selections[id] !== undefined) {
41 | throw new Error("Selection with id already defined: " + id);
42 | }
43 |
44 | const marker = new AceSelectionMarker(this._session, id, label, color, ranges);
45 |
46 | this._selections[id] = marker;
47 | this._session.addDynamicMarker(marker, false);
48 | }
49 |
50 | /**
51 | * Updates the selection for a particular user.
52 | *
53 | * @param id
54 | * The unique identifier for the user.
55 | * @param ranges
56 | * The array of ranges that specify the selection.
57 | */
58 | public setSelection(id: string, ranges: Ace.Range[]) {
59 | const selection = this._getSelection(id);
60 |
61 | selection.setSelection(ranges);
62 | }
63 |
64 | /**
65 | * Clears the selection (but does not remove it) for the specified user.
66 | * @param id
67 | * The unique identifier for the user.
68 | */
69 | public clearSelection(id: string): void {
70 | const selection = this._getSelection(id);
71 |
72 | selection.setSelection(null);
73 | }
74 |
75 | /**
76 | * Removes the selection for the specified user.
77 | * @param id
78 | * The unique identifier for the user.
79 | */
80 | public removeSelection(id: string) {
81 | const selection = this._selections[id];
82 |
83 | if (selection === undefined) {
84 | throw new Error("Selection not found: " + id);
85 | }
86 |
87 | // note: ace adds the id property to whatever marker you pass in.
88 | this._session.removeMarker((selection as any).id);
89 | delete this._selections[id];
90 | }
91 |
92 | /**
93 | * Removes all selections.
94 | */
95 | public removeAll(): void {
96 | Object.getOwnPropertyNames(this._selections).forEach((key) => {
97 | this.removeSelection(this._selections[key].selectionId());
98 | });
99 | }
100 |
101 | private _getSelection(id: string): AceSelectionMarker {
102 | const selection: AceSelectionMarker = this._selections[id];
103 |
104 | if (selection === undefined) {
105 | throw new Error("Selection not found: " + id);
106 | }
107 | return selection;
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/ts/AceMultiCursorManager.ts:
--------------------------------------------------------------------------------
1 | import {Ace} from "ace-builds";
2 | import {AceCursorMarker} from "./AceCursorMarker";
3 |
4 | /**
5 | * Implements multiple colored cursors in the ace editor. Each cursor is
6 | * associated with a particular user. Each user is identified by a unique id
7 | * and has a color associated with them. Each cursor has a position in the
8 | * editor which is specified by a 2-d row and column ({row: 0, column: 10}).
9 | */
10 | export class AceMultiCursorManager {
11 |
12 | private readonly _cursors: { [key: string]: AceCursorMarker };
13 | private readonly _session: Ace.EditSession;
14 |
15 | /**
16 | * Constructs a new AceMultiCursorManager that is bound to a particular
17 | * Ace EditSession instance.
18 | *
19 | * @param session
20 | * The Ace EditSession to bind to.
21 | */
22 | constructor(session: Ace.EditSession) {
23 | this._cursors = {};
24 | this._session = session;
25 | }
26 |
27 | /**
28 | * Adds a new collaborative selection.
29 | *
30 | * @param id
31 | * The unique system identifier for the user associated with this selection.
32 | * @param label
33 | * A human readable / meaningful label / title that identifies the user.
34 | * @param color
35 | * A valid css color string.
36 | * @param position
37 | * A 2-d position or linear index indicating the location of the cursor.
38 | */
39 | public addCursor(id: string, label: string, color: string, position: number | Ace.Point): void {
40 | if (this._cursors[id] !== undefined) {
41 | throw new Error(`Cursor with id already defined: ${id}`);
42 | }
43 |
44 | const marker: AceCursorMarker = new AceCursorMarker(this._session, id, label, color, position);
45 |
46 | this._cursors[id] = marker;
47 | this._session.addDynamicMarker(marker, true);
48 | }
49 |
50 | /**
51 | * Updates the selection for a particular user.
52 | *
53 | * @param id
54 | * The unique identifier for the user.
55 | * @param position
56 | * A 2-d position or linear index indicating the location of the cursor.
57 | */
58 | public setCursor(id: string, position: number | Ace.Point): void {
59 | const cursor: AceCursorMarker = this._getCursor(id);
60 |
61 | cursor.setPosition(position);
62 | }
63 |
64 | /**
65 | * Clears the cursor (but does not remove it) for the specified user.
66 | *
67 | * @param id
68 | * The unique identifier for the user.
69 | */
70 | public clearCursor(id: string): void {
71 | const cursor = this._getCursor(id);
72 |
73 | cursor.setPosition(null);
74 | }
75 |
76 | /**
77 | * Removes the cursor for the specified user.
78 | *
79 | * @param id
80 | * The unique identifier for the user.
81 | */
82 | public removeCursor(id: string): void {
83 | const cursor = this._cursors[id];
84 |
85 | if (cursor === undefined) {
86 | throw new Error(`Cursor not found: ${id}`);
87 | }
88 | // Note: ace adds an id field to all added markers.
89 | this._session.removeMarker((cursor as any).id);
90 | delete this._cursors[id];
91 | }
92 |
93 | /**
94 | * Removes all cursors.
95 | */
96 | public removeAll(): void {
97 | Object.getOwnPropertyNames(this._cursors).forEach((key) => {
98 | this.removeCursor(this._cursors[key].cursorId());
99 | });
100 | }
101 |
102 | private _getCursor(id: string): AceCursorMarker {
103 | const cursor: AceCursorMarker = this._cursors[id];
104 |
105 | if (cursor === undefined) {
106 | throw new Error(`Cursor not found: ${id}`);
107 | }
108 | return cursor;
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/example/editor_contents.js:
--------------------------------------------------------------------------------
1 | var editorContents = `var observableProto;
2 |
3 | /**
4 | * Represents a push-style collection.
5 | */
6 | var Observable = Rx.Observable = (function () {
7 |
8 | function makeSubscribe(self, subscribe) {
9 | return function (o) {
10 | var oldOnError = o.onError;
11 | o.onError = function (e) {
12 | makeStackTraceLong(e, self);
13 | oldOnError.call(o, e);
14 | };
15 |
16 | return subscribe.call(self, o);
17 | };
18 | }
19 |
20 | function Observable() {
21 | if (Rx.config.longStackSupport && hasStacks) {
22 | var oldSubscribe = this._subscribe;
23 | var e = tryCatch(thrower)(new Error()).e;
24 | this.stack = e.stack.substring(e.stack.indexOf('\\n') + 1);
25 | this._subscribe = makeSubscribe(this, oldSubscribe);
26 | }
27 | }
28 |
29 | observableProto = Observable.prototype;
30 |
31 | /**
32 | * Determines whether the given object is an Observable
33 | * @param {Any} An object to determine whether it is an Observable
34 | * @returns {Boolean} true if an Observable, else false.
35 | */
36 | Observable.isObservable = function (o) {
37 | return o && isFunction(o.subscribe);
38 | };
39 |
40 | /**
41 | * Subscribes an o to the observable sequence.
42 | * @param {Mixed} [oOrOnNext] The object that is to receive notifications or an action to invoke for each element in the observable sequence.
43 | * @param {Function} [onError] Action to invoke upon exceptional termination of the observable sequence.
44 | * @param {Function} [onCompleted] Action to invoke upon graceful termination of the observable sequence.
45 | * @returns {Diposable} A disposable handling the subscriptions and unsubscriptions.
46 | */
47 | observableProto.subscribe = observableProto.forEach = function (oOrOnNext, onError, onCompleted) {
48 | return this._subscribe(typeof oOrOnNext === 'object' ?
49 | oOrOnNext :
50 | observerCreate(oOrOnNext, onError, onCompleted));
51 | };
52 |
53 | /**
54 | * Subscribes to the next value in the sequence with an optional "this" argument.
55 | * @param {Function} onNext The function to invoke on each element in the observable sequence.
56 | * @param {Any} [thisArg] Object to use as this when executing callback.
57 | * @returns {Disposable} A disposable handling the subscriptions and unsubscriptions.
58 | */
59 | observableProto.subscribeOnNext = function (onNext, thisArg) {
60 | return this._subscribe(observerCreate(typeof thisArg !== 'undefined' ? function(x) { onNext.call(thisArg, x); } : onNext));
61 | };
62 |
63 | /**
64 | * Subscribes to an exceptional condition in the sequence with an optional "this" argument.
65 | * @param {Function} onError The function to invoke upon exceptional termination of the observable sequence.
66 | * @param {Any} [thisArg] Object to use as this when executing callback.
67 | * @returns {Disposable} A disposable handling the subscriptions and unsubscriptions.
68 | */
69 | observableProto.subscribeOnError = function (onError, thisArg) {
70 | return this._subscribe(observerCreate(null, typeof thisArg !== 'undefined' ? function(e) { onError.call(thisArg, e); } : onError));
71 | };
72 |
73 | /**
74 | * Subscribes to the next value in the sequence with an optional "this" argument.
75 | * @param {Function} onCompleted The function to invoke upon graceful termination of the observable sequence.
76 | * @param {Any} [thisArg] Object to use as this when executing callback.
77 | * @returns {Disposable} A disposable handling the subscriptions and unsubscriptions.
78 | */
79 | observableProto.subscribeOnCompleted = function (onCompleted, thisArg) {
80 | return this._subscribe(observerCreate(null, null, typeof thisArg !== 'undefined' ? function() { onCompleted.call(thisArg); } : onCompleted));
81 | };
82 |
83 | return Observable;
84 | })();`;
--------------------------------------------------------------------------------
/src/ts/AceRadarView.ts:
--------------------------------------------------------------------------------
1 | import {Ace} from "ace-builds";
2 | import {AceRadarViewIndicator} from "./AceRadarViewIndicator";
3 | import {IRowRange} from "./RowRange";
4 |
5 | /**
6 | * Implements viewport awareness in the Ace Editor by showing where remote
7 | * users are scrolled too and where there cursor is in the document, even
8 | * if the cursor is not in view.
9 | */
10 | export class AceRadarView {
11 | private readonly _views: { [key: string]: AceRadarViewIndicator };
12 | private readonly _editor: Ace.Editor;
13 | private _container: HTMLElement;
14 |
15 | /**
16 | * Constructs a new AceRadarView bound to the supplied element and editor.
17 | *
18 | * @param element
19 | * The HTML Element that the AceRadarView should render to.
20 | * @param editor
21 | * The Ace Editor to listen to events from.
22 | */
23 | constructor(element: HTMLElement | string, editor: Ace.Editor) {
24 | this._container = null;
25 | if (typeof element === "string") {
26 | this._container = document.getElementById(element);
27 | } else {
28 | this._container = element;
29 | }
30 |
31 | this._container.style.position = "relative";
32 | this._views = {};
33 | this._editor = editor;
34 | }
35 |
36 | /**
37 | * Add a view indicator for a new remote user.
38 | *
39 | * @param id
40 | * The unique id of the user.
41 | * @param label
42 | * A text label to displAce for the user.
43 | * @param color
44 | * The color to render the indicator with.
45 | * @param viewRows
46 | * The rows the user's viewport spans.
47 | * @param cursorRow
48 | * The row that the user's cursor is on.
49 | */
50 | public addView(id: string, label: string, color: string, viewRows: IRowRange, cursorRow: number) {
51 | const indicator = new AceRadarViewIndicator(
52 | label,
53 | color,
54 | viewRows,
55 | cursorRow,
56 | this._editor
57 | );
58 |
59 | this._container.appendChild(indicator.element());
60 | indicator.update();
61 |
62 | this._views[id] = indicator;
63 | }
64 |
65 | /**
66 | * Determines if the AceRadarView has an indicator for this specified user.
67 | *
68 | * @param id
69 | * The id of the user to check for.
70 | * @returns
71 | * True if the AceRadarView has an indicator for this user, false otherwise.
72 | */
73 | public hasView(id: string): boolean {
74 | return this._views[id] !== undefined;
75 | }
76 |
77 | /**
78 | * Sets the view row span for a particular user.
79 | *
80 | * @param id
81 | * The id of the user to set the rows for.
82 | * @param rows
83 | * The row range to set.
84 | */
85 | public setViewRows(id: string, rows: IRowRange) {
86 | const indicator = this._views[id];
87 | indicator.setViewRows(rows);
88 | }
89 |
90 | /**
91 | * Sets the cursor row for a particular user.
92 | *
93 | * @param id
94 | * The id of the user to set the cursor row for.
95 | * @param row
96 | * The row to set.
97 | */
98 | public setCursorRow(id: string, row: number) {
99 | const indicator = this._views[id];
100 | indicator.setCursorRow(row);
101 | }
102 |
103 | /**
104 | * Clears the view for a particular user, causing their indicator to disapear.
105 | * @param id
106 | * The id of the user to clear.
107 | */
108 | public clearView(id: string): void {
109 | const indicator = this._views[id];
110 | indicator.setCursorRow(null);
111 | indicator.setViewRows(null);
112 | }
113 |
114 | /**
115 | * Removes the view indicator for the specified user.
116 | * @param id
117 | * The id of the user to remove the view indicator for.
118 | */
119 | public removeView(id: string): void {
120 | const indicator = this._views[id];
121 | indicator.dispose();
122 | delete this._views[id];
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Ace Collaborative Extensions
2 | [](https://github.com/convergencelabs/ace-collab-ext/actions/workflows/build.yml)
3 |
4 | Enhances the [Ace Editor](https://github.com/ajaxorg/ace) by adding the ability to render cues about what remote users are doing in the system.
5 |
6 | ## Installation
7 |
8 | Install package with NPM and add it to your development dependencies:
9 |
10 | For versions >= 0.5.0 (current):
11 | ```npm install --save-dev @convergencelabs/ace-collab-ext```
12 |
13 | For versions <= 0.4.0 (previous):
14 | ```npm install --save-dev @convergence/ace-collab-ext```
15 |
16 | ## Demo
17 | Go [here](https://examples.convergence.io/examples/ace/) 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.
18 |
19 | ## Usage
20 |
21 | ### CSS
22 | Be sure to include one of CSS files located in the css directory of the node modules:
23 |
24 | * `css/ace-collab-ext.css`
25 | * `css/ace-collab-ext.min.css`
26 |
27 | How to do this will depend on how you are packaging and distributing your application. For example if you are bundling your css / sass / less you might be able to use an `@import` statement or you might `require` it. If you are hotlinking, you might need to at a `` tag to your document.
28 |
29 | If you forget to include the styles, its likely that the remote cursors / selections will either not show up, or they will not properly move.
30 |
31 | ### Multi Cursor Manager
32 | The multi cursor manager allows you to easily render the cursors of other users
33 | working in the same document. The cursor position can be represented as either
34 | a single linear index or as a 2-dimensional position in the form of
35 | ```{row: 0, column: 10}```.
36 |
37 | ```javascript
38 | const editor = ace.edit("editor");
39 | const curMgr = new AceCollabExt.AceMultiCursorManager(editor.getSession());
40 |
41 | // Add a new remote cursor with an id of "uid1", and a color of orange.
42 | curMgr.addCursor("uid1", "User 1", "orange", {row: 0, column: 10});
43 |
44 | // Set cursor for "uid1" to index 10.
45 | curMgr.setCursor("uid1", 10);
46 |
47 | // Clear the remote cursor for "uid1" without removing it.
48 | curMgr.clearCursor("uid1");
49 |
50 | // Remove the remote cursor for "uid1".
51 | curMgr.removeCursor("uid1");
52 | ```
53 |
54 | ### Multi Selection Manager
55 | The multi selection manager allows you to easily render the selection of other
56 | users working in the same document. Selection is represented by an array of
57 | AceRanges. A single range is common for normal selection, but multiple ranges
58 | are needed to support block selection.
59 |
60 | ```javascript
61 | const AceRange = ace.require("ace/range");
62 |
63 | const editor = ace.edit("editor");
64 | const selMgr = new AceCollabExt.AceMultiSelectionManager(editor.getSession());
65 |
66 | // A two-line block selection
67 | const initialRanges = [
68 | new AceRange(0, 0, 0, 10),
69 | new AceRange(1, 0, 1, 10),
70 | ];
71 |
72 | // Add a new remote view indicator with an id of "uid1", and a color of orange.
73 | selMgr.addSelection("uid1", "User 1", "orange", initialRanges);
74 |
75 | // Set the selection to a new range.
76 | selMgr.setSelection("uid1", new AceRange(10, 0, 11, 10));
77 |
78 | // Nullify the selection without removing the marker.
79 | selMgr.clearSelection("uid1");
80 |
81 | // Remove the remote view indicator for "uid1".
82 | selMgr.removeSelection("uid1");
83 | ```
84 |
85 | ### Radar View
86 | A radar view indicates where in a document another user is looking and allows
87 | you to easily go to the location in the document.
88 |
89 | ```javascript
90 | const editor = ace.edit("editor");
91 | const radarView = new AceCollabExt.RadarView("radarView", editor);
92 |
93 | // Add a new remote view indicator with an id of "uid1", and a color of orange.
94 | radarView.addView("uid1", "user1", "orange", 0, 20, 0);
95 |
96 | // Set the viewport range of the indicator to span rows 10 through 40.
97 | radarView.setViewRows("uid1", 10, 40);
98 |
99 | // Set the row location of the cursor to line 10.
100 | radarView.setCursorRow("uid1", 10);
101 |
102 | // Remove the remote view indicator for "uid1".
103 | radarView.removeView("uid1");
104 | ```
105 |
--------------------------------------------------------------------------------
/src/ts/AceRadarViewIndicator.ts:
--------------------------------------------------------------------------------
1 | import {Ace} from "ace-builds";
2 | import {IRowRange} from "./RowRange";
3 |
4 | export class AceRadarViewIndicator {
5 |
6 | private readonly _label: string;
7 | private readonly _color: string;
8 | private readonly _editorListener: () => void;
9 | private readonly _scrollElement: HTMLDivElement;
10 | private readonly _cursorElement: HTMLDivElement;
11 | private readonly _wrapper: HTMLDivElement;
12 | private _viewRows: IRowRange;
13 | private _cursorRow: number;
14 | private _editor: Ace.Editor;
15 | private _docLineCount: number;
16 |
17 | constructor(label: string, color: string, viewRows: IRowRange, cursorRow: number, editor: Ace.Editor) {
18 | this._label = label;
19 | this._color = color;
20 | this._viewRows = viewRows;
21 | this._cursorRow = cursorRow;
22 | this._editor = editor;
23 | this._docLineCount = editor.getSession().getLength();
24 |
25 | this._editorListener = () => {
26 | const newLineCount = this._editor.getSession().getLength();
27 |
28 | if (newLineCount !== this._docLineCount) {
29 | this._docLineCount = newLineCount;
30 | this.update();
31 | }
32 | };
33 | this._editor.on("change", this._editorListener);
34 |
35 | this._scrollElement = document.createElement("div");
36 | this._scrollElement.className = "ace-radar-view-scroll-indicator";
37 |
38 | this._scrollElement.style.borderColor = this._color;
39 | this._scrollElement.style.background = this._color;
40 |
41 | // todo implement a custom tooltip for consistent presentation.
42 | this._scrollElement.title = this._label;
43 |
44 | this._scrollElement.addEventListener("click", () => {
45 | const middle = ((this._viewRows.end - this._viewRows.start) / 2) + this._viewRows.start;
46 |
47 | this._editor.scrollToLine(middle, true, false, () => { /* no-op */
48 | });
49 | }, false);
50 |
51 | this._cursorElement = document.createElement("div");
52 | this._cursorElement.className = "ace-radar-view-cursor-indicator";
53 | this._cursorElement.style.background = this._color;
54 | this._cursorElement.title = this._label;
55 |
56 | this._cursorElement.addEventListener("click", () => {
57 | this._editor.scrollToLine(this._cursorRow, true, false, () => { /* no-op */
58 | });
59 | }, false);
60 |
61 | this._wrapper = document.createElement("div");
62 | this._wrapper.className = "ace-radar-view-wrapper";
63 | this._wrapper.style.display = "none";
64 |
65 | this._wrapper.appendChild(this._scrollElement);
66 | this._wrapper.appendChild(this._cursorElement);
67 | }
68 |
69 | public element(): HTMLDivElement {
70 | return this._wrapper;
71 | }
72 |
73 | public setCursorRow(cursorRow: number): void {
74 | this._cursorRow = cursorRow;
75 | this.update();
76 | }
77 |
78 | public setViewRows(viewRows: IRowRange): void {
79 | this._viewRows = viewRows;
80 | this.update();
81 | }
82 |
83 | public update(): void {
84 | if (!_isSet(this._viewRows) && !_isSet(this._cursorRow)) {
85 | this._wrapper.style.display = "none";
86 | } else {
87 | this._wrapper.style.display = null;
88 | const maxLine = this._docLineCount - 1;
89 |
90 | if (!_isSet(this._viewRows)) {
91 | this._scrollElement.style.display = "none";
92 | } else {
93 | const topPercent = Math.min(maxLine, this._viewRows.start) / maxLine * 100;
94 | const bottomPercent = 100 - (Math.min(maxLine, this._viewRows.end) / maxLine * 100);
95 |
96 | this._scrollElement.style.top = topPercent + "%";
97 | this._scrollElement.style.bottom = bottomPercent + "%";
98 | this._scrollElement.style.display = null;
99 | }
100 |
101 | if (!_isSet(this._cursorRow)) {
102 | this._cursorElement.style.display = "none";
103 | } else {
104 | const cursorPercent = Math.min(this._cursorRow, maxLine) / maxLine;
105 | const ratio = (this._wrapper.offsetHeight - this._cursorElement.offsetHeight) / this._wrapper.offsetHeight;
106 | const cursorTop = cursorPercent * ratio * 100;
107 |
108 | this._cursorElement.style.top = cursorTop + "%";
109 | this._cursorElement.style.display = null;
110 | }
111 | }
112 | }
113 |
114 | public dispose(): void {
115 | this._wrapper.parentNode.removeChild(this._wrapper);
116 | this._editor.off("change", this._editorListener);
117 | }
118 | }
119 |
120 | function _isSet(value: any): boolean {
121 | return value !== undefined && value !== null;
122 | }
123 |
--------------------------------------------------------------------------------
/src/ts/AceSelectionMarker.ts:
--------------------------------------------------------------------------------
1 | import {Ace} from "ace-builds";
2 |
3 | export interface ISelectionBounds {
4 | height?: number;
5 | width?: number;
6 | top?: number;
7 | left?: number;
8 | bottom?: number;
9 | right?: number;
10 | }
11 |
12 | export class AceSelectionMarker implements Ace.MarkerLike {
13 | public range: Ace.Range;
14 | public type: string;
15 | public renderer?: Ace.MarkerRenderer;
16 | public clazz: string;
17 | public inFront: boolean;
18 | public id: number;
19 |
20 | private _session: Ace.EditSession;
21 | private readonly _label: string;
22 | private readonly _color: string;
23 | private _ranges: Ace.Range[];
24 | private readonly _selectionId: string;
25 | private readonly _id: string;
26 | private readonly _markerElement: HTMLDivElement;
27 |
28 | constructor(session: Ace.EditSession, selectionId: string, label: string, color: string, ranges: Ace.Range[]) {
29 | this._session = session;
30 | this._label = label;
31 | this._color = color;
32 | this._ranges = ranges || [];
33 | this._selectionId = selectionId;
34 | this._id = null;
35 | this._markerElement = document.createElement("div");
36 | }
37 |
38 | public update(_: string[], markerLayer: any, session: Ace.EditSession, layerConfig: any): void {
39 | while (this._markerElement.hasChildNodes()) {
40 | this._markerElement.removeChild(this._markerElement.lastChild);
41 | }
42 |
43 | this._ranges.forEach((range) => {
44 | this._renderRange(markerLayer, session, layerConfig, range);
45 | });
46 |
47 | this._markerElement.remove();
48 | markerLayer.elt("remote-selection", "");
49 | const parentNode = markerLayer.element.childNodes[markerLayer.i - 1] || markerLayer.element.lastChild;
50 | parentNode.appendChild(this._markerElement);
51 | }
52 |
53 | public setSelection(ranges: Ace.Range[]): void {
54 | if (ranges === undefined || ranges === null) {
55 | this._ranges = [];
56 | } else if (ranges instanceof Array) {
57 | this._ranges = ranges;
58 | } else {
59 | this._ranges = [ranges];
60 | }
61 |
62 | this._forceSessionUpdate();
63 | }
64 |
65 | public getLabel(): string {
66 | return this._label;
67 | }
68 |
69 | public selectionId(): string {
70 | return this._selectionId;
71 | }
72 |
73 | public markerId(): string {
74 | return this._id;
75 | }
76 |
77 | private _renderLine(bounds: ISelectionBounds): void {
78 | const div = document.createElement("div");
79 | div.className = "ace-multi-selection";
80 | div.style.backgroundColor = this._color;
81 |
82 | if (typeof bounds.height === "number") {
83 | div.style.height = `${bounds.height}px`;
84 | }
85 |
86 | if (typeof bounds.width === "number") {
87 | div.style.width = `${bounds.width}px`;
88 | }
89 |
90 | if (typeof bounds.top === "number") {
91 | div.style.top = `${bounds.top}px`;
92 | }
93 |
94 | if (typeof bounds.left === "number") {
95 | div.style.left = `${bounds.left}px`;
96 | }
97 |
98 | if (typeof bounds.bottom === "number") {
99 | div.style.bottom = `${bounds.bottom}px`;
100 | }
101 |
102 | if (typeof bounds.right === "number") {
103 | div.style.right = `${bounds.right}px`;
104 | }
105 |
106 | this._markerElement.append(div);
107 | }
108 |
109 | private _renderRange(markerLayer: any, session: Ace.EditSession, layerConfig: any, range: Ace.Range): void {
110 | const screenRange: Ace.Range = range.toScreenRange(session);
111 |
112 | let height: number = layerConfig.lineHeight;
113 | let top: number = markerLayer.$getTop(screenRange.start.row, layerConfig);
114 | let width: number = 0;
115 | const right = 0;
116 | const left: number = markerLayer.$padding + screenRange.start.column * layerConfig.characterWidth;
117 |
118 | if (screenRange.isMultiLine()) {
119 | // Render the start line
120 | this._renderLine({height, right, top, left});
121 |
122 | // from start of the last line to the selection end
123 | top = markerLayer.$getTop(screenRange.end.row, layerConfig);
124 | width = screenRange.end.column * layerConfig.characterWidth;
125 | this._renderLine({height, width, top, left: markerLayer.$padding});
126 |
127 | // all the complete lines
128 | height = (screenRange.end.row - screenRange.start.row - 1) * layerConfig.lineHeight;
129 | if (height < 0) {
130 | return;
131 | }
132 | top = markerLayer.$getTop(screenRange.start.row + 1, layerConfig);
133 | this._renderLine({height, right, top, left: markerLayer.$padding});
134 | } else {
135 | width = (range.end.column - range.start.column) * layerConfig.characterWidth;
136 | this._renderLine({height, width, top, left});
137 | }
138 | }
139 |
140 | private _forceSessionUpdate(): void {
141 | (this._session as any)._signal("changeBackMarker");
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/src/ts/AceCursorMarker.ts:
--------------------------------------------------------------------------------
1 | import {Ace} from "ace-builds";
2 |
3 | /**
4 | * Represents a marker of a remote users cursor.
5 | */
6 | export class AceCursorMarker implements Ace.MarkerLike {
7 |
8 | public range: Ace.Range;
9 | public type: string;
10 | public renderer?: Ace.MarkerRenderer;
11 | public clazz: string;
12 | public inFront: boolean;
13 | public id: number;
14 |
15 | private readonly _session: Ace.EditSession;
16 | private readonly _label: string;
17 | private readonly _color: string;
18 | private readonly _cursorId: string;
19 | private readonly _id: string;
20 | private readonly _markerElement: HTMLDivElement;
21 | private readonly _cursorElement: HTMLDivElement;
22 | private readonly _tooltipElement: HTMLDivElement;
23 | private _visible: boolean;
24 | private _position: Ace.Point;
25 | private _tooltipTimeout: any;
26 |
27 | /**
28 | * Constructs a new AceCursorMarker
29 | * @param session The Ace Editor Session to bind to.
30 | * @param cursorId the unique id of this cursor.
31 | * @param label The label to display over the cursor.
32 | * @param color The css color of the cursor
33 | * @param position The row / column coordinate of the cursor marker.
34 | */
35 | constructor(session: Ace.EditSession,
36 | cursorId: string,
37 | label: string,
38 | color: string,
39 | position: number | Ace.Point) {
40 | this._session = session;
41 | this._label = label;
42 | this._color = color;
43 | this._position = position ? this._convertPosition(position) : null;
44 | this._cursorId = cursorId;
45 | this._id = null;
46 | this._visible = false;
47 | this._tooltipTimeout = null;
48 |
49 | // Create the HTML elements
50 | this._markerElement = document.createElement("div");
51 | this._cursorElement = document.createElement("div");
52 | this._cursorElement.className = "ace-multi-cursor";
53 | this._cursorElement.style.background = this._color;
54 | this._markerElement.append(this._cursorElement);
55 |
56 | this._tooltipElement = document.createElement("div");
57 | this._tooltipElement.className = "ace-multi-cursor-tooltip";
58 | this._tooltipElement.style.background = this._color;
59 | this._tooltipElement.style.opacity = "0";
60 | this._tooltipElement.innerHTML = label;
61 | this._markerElement.append(this._tooltipElement);
62 | }
63 |
64 | /**
65 | * Called by Ace to update the rendering of the marker.
66 | *
67 | * @param _ The html to render, represented by an array of strings.
68 | * @param markerLayer The marker layer containing the cursor marker.
69 | * @param __ The ace edit session.
70 | * @param layerConfig
71 | */
72 | public update(_: string[], markerLayer: any, __: Ace.EditSession, layerConfig: any): void {
73 | if (this._position === null) {
74 | return;
75 | }
76 |
77 | const screenPosition = this._session.documentToScreenPosition(
78 | this._position.row, this._position.column);
79 |
80 | const top: number = markerLayer.$getTop(screenPosition.row, layerConfig);
81 | const left: number = markerLayer.$padding + screenPosition.column * layerConfig.characterWidth;
82 | const height: number = layerConfig.lineHeight;
83 |
84 | const cursorTop = top + 2;
85 | const cursorHeight = height - 3;
86 | const cursorLeft = left;
87 | const cursorWidth = 2;
88 |
89 | this._cursorElement.style.height = `${cursorHeight}px`;
90 | this._cursorElement.style.width = `${cursorWidth}px`;
91 | this._cursorElement.style.top = `${cursorTop}px`;
92 | this._cursorElement.style.left = `${cursorLeft}px`;
93 |
94 | let toolTipTop = cursorTop - height;
95 | if (toolTipTop < 5) {
96 | toolTipTop = cursorTop + height - 1;
97 | }
98 |
99 | const toolTipLeft = cursorLeft;
100 | this._tooltipElement.style.top = `${toolTipTop - 2}px`;
101 | this._tooltipElement.style.left = `${toolTipLeft - 2}px`;
102 |
103 | // Remove the content node from whatever parent it might have now
104 | // and add it to the new parent node.
105 | this._markerElement.remove();
106 | markerLayer.elt("remote-cursor", "");
107 | const parentNode = markerLayer.element.childNodes[markerLayer.i - 1] || markerLayer.element.lastChild;
108 | parentNode.appendChild(this._markerElement);
109 | }
110 |
111 | /**
112 | * Sets the location of the cursor marker.
113 | * @param position The position of cursor marker.
114 | */
115 | public setPosition(position: number | Ace.Point): void {
116 | this._position = this._convertPosition(position);
117 | this._forceSessionUpdate();
118 | this._tooltipElement.style.opacity = "1";
119 | this._scheduleTooltipHide();
120 | }
121 |
122 | /**
123 | * Sets the marker to visible / invisible.
124 | *
125 | * @param visible true if the marker should be displayed, false otherwise.
126 | */
127 | public setVisible(visible: boolean): void {
128 | const old = this._visible;
129 |
130 | this._visible = visible;
131 | if (old !== this._visible) {
132 | this._markerElement.style.visibility = visible ? "visible" : "hidden";
133 | this._forceSessionUpdate();
134 | }
135 | }
136 |
137 | /**
138 | * Determines if the marker should be visible.
139 | *
140 | * @returns true if the cursor should be visible, false otherwise.
141 | */
142 | public isVisible(): boolean {
143 | return this._visible;
144 | }
145 |
146 | /**
147 | * Gets the unique id of this cursor.
148 | * @returns the unique id of this cursor.
149 | */
150 | public cursorId(): string {
151 | return this._cursorId;
152 | }
153 |
154 | /**
155 | * Gets the id of the marker.
156 | * @returns The marker id.
157 | */
158 | public markerId(): string {
159 | return this._id;
160 | }
161 |
162 | /**
163 | * Gets the label of the marker.
164 | * @returns The marker"s label.
165 | */
166 | public getLabel(): string {
167 | return this._label;
168 | }
169 |
170 | private _forceSessionUpdate(): void {
171 | (this._session as any)._signal("changeFrontMarker");
172 | }
173 |
174 | private _convertPosition(position: number | Ace.Point): Ace.Point {
175 | if (position === null) {
176 | return null;
177 | } else if (typeof position === "number") {
178 | return this._session.getDocument().indexToPosition(position, 0);
179 | } else if (typeof position.row === "number" && typeof position.column === "number") {
180 | return position;
181 | }
182 |
183 | throw new Error(`Invalid position: ${position}`);
184 | }
185 |
186 | private _scheduleTooltipHide(): void {
187 | if (this._tooltipTimeout !== null) {
188 | clearTimeout(this._tooltipTimeout);
189 | }
190 |
191 | this._tooltipTimeout = setTimeout(() => {
192 | this._tooltipElement.style.opacity = "0";
193 | this._tooltipTimeout = null;
194 | }, 2000);
195 | }
196 | }
197 |
--------------------------------------------------------------------------------