├── .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 |
25 |
26 |
27 |
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 | [![example workflow](https://github.com/convergencelabs/ace-collab-ext/actions/workflows/build.yml/badge.svg)](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 | --------------------------------------------------------------------------------