├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── assets └── shared-cursors-and-selections.gif ├── bs-config.json ├── copyright-header.txt ├── example ├── data.js ├── example.css ├── example.js └── index.html ├── package-lock.json ├── package.json ├── rollup.config.js ├── scripts ├── build-css.js └── enhance-types.js ├── src ├── css │ └── html-text-collab-ext.css └── ts │ ├── CollaborativeSelectionManager.ts │ ├── CollaborativeTextArea.ts │ ├── CollaboratorSelection.ts │ ├── ICollaboratieTextAreaOptions.ts │ ├── ICollaborativeSelectionManagerOptions.ts │ ├── ICollaboratorSelectionOptions.ts │ ├── ICursorCoordinates.ts │ ├── ISelectionRange.ts │ ├── ISelectionRow.ts │ ├── ITextInputManagerOptions.ts │ ├── IndexUtils.ts │ ├── SelectionComputer.ts │ ├── TextInputManager.ts │ └── index.ts ├── tsconfig.json └── tslint.json /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .rpt2_cache 3 | node_modules 4 | dist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Convergence Labs, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## HTML Text Collaborative Extensions 2 | [![Build](https://github.com/convergencelabs/html-text-collab-ext/actions/workflows/build.yml/badge.svg) 3 | ](https://github.com/convergencelabs/html-text-collab-ext/actions/workflows/build.yml) 4 | 5 | A set of utilities that enhances a normal HTML ` 47 | 48 | 49 | ``` 50 | 51 | ### JavaScript 52 | ```javascript 53 | const textarea = document.getElementById("example"); 54 | const textEditor = new HtmlTextCollabExt.CollaborativeTextArea({ 55 | control: textarea, 56 | onInsert: (index, value) => console.log(`"${value}" was inserted at index ${index}`, 57 | onDelete: (index, length) => console.log(`"${length}" characters were deleted at index ${index}`, 58 | onSelectionChanged: (selection) => console.log(`selection was changed to ${JSON.stringify(selection)}`) 59 | } 60 | 61 | // 62 | // Selection Management 63 | // 64 | const selectionManager = textEditor.selectionManager(); 65 | 66 | const collaborator = selectionManager.createCollaborator( 67 | "test", "Test User", "red", {anchor: 10, target: 20}); 68 | collaborator.setSelection({anchor: 5, target: 10}); 69 | collaborator.flashCursorToolTip(2); 70 | 71 | selectionManager.removeCollaborator("test"); 72 | 73 | // 74 | // Text Modification 75 | // 76 | 77 | // Insert text at index 10 78 | textEditor.insertText(10, "Inserted Text"); 79 | 80 | // Delete 5 charachters at index 10 81 | textEditor.deleteText(10, 5); 82 | 83 | // Set the entire value. 84 | textEditor.setText("New textarea value"); 85 | ``` 86 | 87 | ## Development 88 | 89 | - Use `npm install` to install required dependencies. 90 | - Use `npm run dist` to build the distribution package. 91 | - Use `npm start` to start the example application. 92 | 93 | -------------------------------------------------------------------------------- /assets/shared-cursors-and-selections.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/convergencelabs/html-text-collab-ext/d78433e4d045430d0767b03cdeabf1c18d701a28/assets/shared-cursors-and-selections.gif -------------------------------------------------------------------------------- /bs-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "ghostMode": false, 3 | "server": { 4 | "baseDir": "example", 5 | "routes": { 6 | "/libs": "node_modules", 7 | "/dist": "dist" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /copyright-header.txt: -------------------------------------------------------------------------------- 1 | © 2018-2021 Convergence Labs, Inc. 2 | @version <%= pkg.version %> 3 | @license MIT -------------------------------------------------------------------------------- /example/data.js: -------------------------------------------------------------------------------- 1 | const TEXT_DATA = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Viverra tellus in hac habitasse platea dictumst vestibulum. Ullamcorper velit sed ullamcorper morbi tincidunt ornare massa eget. Ut morbi tincidunt augue interdum. Sollicitudin aliquam ultrices sagittis orci a. Tortor consequat id porta nibh venenatis cras sed felis eget. Mi proin sed libero enim. Maecenas volutpat blandit aliquam etiam erat velit. Eros donec ac odio tempor orci dapibus ultrices in iaculis. Amet facilisis magna etiam tempor orci. Et malesuada fames ac turpis egestas maecenas. Scelerisque eleifend donec pretium vulputate sapien nec sagittis. Dictum at tempor commodo ullamcorper a lacus vestibulum. Ultrices vitae auctor eu augue ut lectus arcu bibendum at. Ultrices in iaculis nunc sed augue lacus viverra. Dignissim suspendisse in est ante in. Feugiat nisl pretium fusce id velit ut tortor pretium viverra. 2 | 3 | Elementum pulvinar etiam non quam lacus suspendisse faucibus interdum posuere. Mauris cursus mattis molestie a iaculis. Duis tristique sollicitudin nibh sit. Tortor consequat id porta nibh venenatis cras sed. Scelerisque eu ultrices vitae auctor eu augue ut lectus. Donec pretium vulputate sapien nec sagittis aliquam. Ultrices gravida dictum fusce ut placerat. Sagittis eu volutpat odio facilisis. Cum sociis natoque penatibus et. Odio eu feugiat pretium nibh ipsum consequat nisl vel. Egestas purus viverra accumsan in nisl nisi scelerisque eu. Scelerisque eu ultrices vitae auctor eu augue. Tempor orci eu lobortis elementum nibh tellus. 4 | 5 | Fringilla urna porttitor rLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Adipiscing enim eu turpis egestas pretium aenean pharetra magna. Ac felis donec et odio pellentesque diam volutpat commodo. At auctor urna nunc id cursus metus. Turpis egestas maecenas pharetra convallis posuere morbi leo. Velit egestas dui id ornare. Pretium fusce id velit ut. Interdum velit euismod in pellentesque massa. Lacus viverra vitae congue eu consequat ac felis donec et. Malesuada bibendum arcu vitae elementum. 6 | 7 | Massa placerat duis ultricies lacus sed. Cursus eget nunc scelerisque viverra mauris in. Eu consequat ac felis donec. Convallis posuere morbi leo urna molestie. Sit amet porttitor eget dolor morbi non arcu. Elementum integer enim neque volutpat ac tincidunt vitae semper. In nisl nisi scelerisque eu ultrices. Orci dapibus ultrices in iaculis. Vel eros donec ac odio. Pellentesque id nibh tortor id aliquet lectus proin. Non arcu risus quis varius quam quisque id. Quam adipiscing vitae proin sagittis nisl rhoncus mattis rhoncus. Purus faucibus ornare suspendisse sed. Aenean pharetra magna ac placerat vestibulum. Ultrices neque ornare aenean euismod elementum nisi. 8 | 9 | Viverra justo nec ultrices dui sapien eget mi proin. Consectetur a erat nam at lectus urna duis. Aliquet eget sit amet tellus cras. Elit ut aliquam purus sit amet. Lorem sed risus ultricies tristique nulla aliquet enim tortor at. Tempus quam pellentesque nec nam aliquam sem et. Porttitor rhoncus dolor purus non enim praesent elementum. Erat imperdiet sed euismod nisi porta. Egestas egestas fringilla phasellus faucibus. Platea dictumst quisque sagittis purus sit. Arcu dictum varius duis at consectetur lorem donec massa sapien. Ornare massa eget egestas purus viverra accumsan. Sagittis purus sit amet volutpat consequat mauris nunc congue nisi. Ultricies leo integer malesuada nunc vel risus commodo viverra. Sed cras ornare arcu dui vivamus. Viverra nibh cras pulvinar mattis nunc sed blandit libero volutpat. Orci eu lobortis elementum nibh tellus molestie nunc. Sed viverra tellus in hac. Lorem donec massa sapien faucibus et molestie ac feugiat. In est ante in nibh. 10 | 11 | A scelerisque purus semper eget duis at tellus at urna. Nibh sed pulvinar proin gravida hendrerit lectus a. Viverra suspendisse potenti nullam ac tortor. Duis at consectetur lorem donec massa sapien. Facilisi cras fermentum odio eu feugiat pretium nibh ipsum consequat. Odio morbi quis commodo odio aenean sed adipiscing. Odio pellentesque diam volutpat commodo sed. Ut eu sem integer vitae. Adipiscing diam donec adipiscing tristique risus nec feugiat in. Sit amet porttitor eget dolor. Scelerisque in dictum non consectetur a erat nam. Sed vulputate mi sit amet mauris. Tristique magna sit amet purus gravida quis blandit turpis cursus. Diam quam nulla porttitor massa id neque aliquam. Convallis posuere morbi leo urna molestie at. Ultrices vitae auctor eu augue ut lectus arcu bibendum at. Congue nisi vitae suscipit tellus mauris a. A pellentesque sit amet porttitor eget dolor morbi. 12 | 13 | Dolor morbi non arcu risus quis varius quam quisque. Turpis egestas maecenas pharetra convallis posuere morbi leo. Auctor elit sed vulputate mi sit amet. Sollicitudin ac orci phasellus egestas tellus rutrum tellus pellentesque eu. Nisi quis eleifend quam adipiscing vitae. Posuere lorem ipsum dolor sit. Habitant morbi tristique senectus et netus. Elementum tempus egestas sed sed. Aliquam etiam erat velit scelerisque in. Urna et pharetra pharetra massa. Dictumst quisque sagittis purus sit amet volutpat consequat mauris nunc. Vestibulum mattis ullamcorper velit sed ullamcorper. Egestas erat imperdiet sed euismod nisi porta lorem. Augue lacus viverra vitae congue eu. Egestas sed sed risus pretium quam.`; -------------------------------------------------------------------------------- /example/example.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Helvetica Neue", sans-serif; 3 | } 4 | 5 | div.main { 6 | width: 800px; 7 | margin: auto; 8 | } 9 | 10 | div.header { 11 | border-bottom: 1px solid black; 12 | margin-bottom: 10px; 13 | } 14 | 15 | div.header h1 { 16 | font-size: 18px; 17 | margin-bottom: 5px; 18 | } 19 | 20 | div.description { 21 | background: #EEEEEE; 22 | border-radius: 3px; 23 | border: 1px solid lightgrey; 24 | margin-bottom: 10px; 25 | font-size: 12px; 26 | padding: 4px; 27 | } 28 | 29 | div.content { 30 | display: flex; 31 | } 32 | 33 | div.content div.col { 34 | display: flex; 35 | flex-direction: column; 36 | flex: 1; 37 | } 38 | 39 | div.content div.col:first-child { 40 | margin-right: 20px; 41 | } 42 | 43 | textarea { 44 | height: 300px; 45 | line-height: 1.2; 46 | resize: none; 47 | } 48 | 49 | label { 50 | margin-bottom: 5px; 51 | } 52 | -------------------------------------------------------------------------------- /example/example.js: -------------------------------------------------------------------------------- 1 | const textarea1 = document.getElementById("textarea1"); 2 | textarea1.value = TEXT_DATA; 3 | textarea1.selectionStart = 0; 4 | textarea1.selectionEnd = 0; 5 | 6 | const textarea2 = document.getElementById("textarea2"); 7 | textarea2.value = TEXT_DATA; 8 | textarea2.selectionStart = 0; 9 | textarea2.selectionEnd = 0; 10 | 11 | const editor1 = new HtmlTextCollabExt.CollaborativeTextArea({ 12 | control: textarea1, 13 | onInsert: (index, value) => { 14 | editor2.insertText(index, value); 15 | }, 16 | onDelete: (index, length) => { 17 | editor2.deleteText(index, length); 18 | }, 19 | onSelectionChanged: (selection) => { 20 | collaborator1.setSelection(selection); 21 | collaborator1.flashCursorToolTip(2); 22 | } 23 | }); 24 | 25 | const editor2 = new HtmlTextCollabExt.CollaborativeTextArea({ 26 | control: textarea2, 27 | onInsert: (index, value) => { 28 | editor1.insertText(index, value); 29 | }, 30 | onDelete: (index, length) => { 31 | editor1.deleteText(index, length); 32 | }, 33 | onSelectionChanged: (selection) => { 34 | collaborator2.setSelection(selection); 35 | collaborator2.flashCursorToolTip(2); 36 | } 37 | }); 38 | 39 | const collaborator2 = editor1.selectionManager().addCollaborator("user2", "User 2", "blue"); 40 | const collaborator1 = editor2.selectionManager().addCollaborator("user1", "User 1", "red"); -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | HTML Text Collaborative Extensions 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |

Html Text Collaborative Extensions

17 |
18 |
19 | This example demonstrates the HTML Text Collaborative Extensions library which transforms a normal Html Text Area 20 | into a collaborative text editor able to render remote cursors and selections of other users. It also allows the 21 | Text Area to be non-disruptively modified by inserting and removing text without disturbing the cursor or selection 22 | of the local user. A tooltip is provided and can be flashed based on the actions of the other users. 23 |
24 |
25 |
26 | 27 | 28 |
29 |
30 | 31 | 32 |
33 |
34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@convergence/html-text-collab-ext", 3 | "version": "0.3.1", 4 | "description": "Collaborative extension for HTML TextAreas", 5 | "keywords": [ 6 | "collaboration", 7 | "textarea", 8 | "editor", 9 | "html" 10 | ], 11 | "homepage": "http://convergencelabs.com", 12 | "author": { 13 | "name": "Convergence Labs, Inc.", 14 | "email": "info@convergencelabs.com", 15 | "url": "http://convergencelabs.com" 16 | }, 17 | "contributors": [], 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/convergencelabs/html-text-collab-ext.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/convergencelabs/html-text-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 | "start": "lite-server" 36 | }, 37 | "publishConfig": { 38 | "registry": "https://registry.npmjs.org/", 39 | "access": "public" 40 | }, 41 | "main": "dist/lib/index.js", 42 | "module": "dist/module/index.js", 43 | "types": "dist/types/index.d.ts", 44 | "browser": "dist/umd/html-text-collab-ext.js", 45 | "files": [ 46 | "dist", 47 | "example" 48 | ], 49 | "dependencies": { 50 | "@convergence/string-change-detector": "^0.1.8", 51 | "textarea-caret": "git+https://git@github.com/convergencelabs/textarea-caret-position.git#5e8241d6a7c0cbaa7bb9415b58dcff3b5b37064f" 52 | }, 53 | "devDependencies": { 54 | "@rollup/plugin-commonjs": "^19.0.1", 55 | "@rollup/plugin-node-resolve": "^13.0.2", 56 | "@rollup/plugin-typescript": "^8.2.3", 57 | "clean-css": "^5.1.3", 58 | "fs-extra": "^10.0.0", 59 | "lite-server": "^2.6.1", 60 | "rimraf": "^3.0.2", 61 | "rollup": "2.47.0", 62 | "rollup-plugin-license": "^2.5.0", 63 | "rollup-plugin-terser": "^7.0.2", 64 | "tslib": "^2.3.0", 65 | "typescript": "^4.3.5" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /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 = "HtmlTextCollabExt"; 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 | }]; -------------------------------------------------------------------------------- /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/html-text-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/html-text-collab-ext.min.css', minified.styles ); 13 | fs.writeFileSync( 'dist/css/html-text-collab-ext.css.map', minified.sourceMap.toString() ); 14 | -------------------------------------------------------------------------------- /scripts/enhance-types.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | fs.appendFileSync('dist/types/index.d.ts', '\nexport as namespace HtmlTextCollabExt;\n'); 4 | 5 | -------------------------------------------------------------------------------- /src/css/html-text-collab-ext.css: -------------------------------------------------------------------------------- 1 | div.text-collab-ext { 2 | position: absolute; 3 | pointer-events: none; 4 | overflow: hidden; 5 | } 6 | 7 | div.text-collab-ext div.text-collab-ext-scroller { 8 | position: absolute; 9 | width: 100%; 10 | } 11 | 12 | div.text-collab-ext div.collaborator-cursor { 13 | position: absolute; 14 | width: 2px; 15 | opacity: 0.8; 16 | } 17 | 18 | div.text-collab-ext div.collaborator-cursor-tooltip { 19 | position: absolute; 20 | white-space: nowrap; 21 | color: #FFFFFF; 22 | text-shadow: 0 0 1px #000000; 23 | opacity: 1.0; 24 | font-size: 12px; 25 | padding: 2px; 26 | font-family: sans-serif; 27 | 28 | transition: opacity 0.5s ease-out; 29 | -webkit-transition: opacity 0.5s ease-out; 30 | -moz-transition: opacity 0.5s ease-out; 31 | -ms-transition: opacity 0.5s ease-out; 32 | -o-transition: opacity 0.5s ease-out; 33 | } -------------------------------------------------------------------------------- /src/ts/CollaborativeSelectionManager.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Convergence Labs, Inc. 3 | * 4 | * This file is part of the HTML Text Collaborative Extensions, which is 5 | * released under the terms of the MIT license. A copy of the MIT license 6 | * is usually provided as part of this source code package in the LICENCE 7 | * file. If it was not, please see 8 | */ 9 | 10 | import {ISelectionRange} from "./ISelectionRange"; 11 | import {CollaboratorSelection} from "./CollaboratorSelection"; 12 | import {IndexUtils} from "./IndexUtils"; 13 | import {ICollaborativeSelectionManagerOptions} from "./ICollaborativeSelectionManagerOptions"; 14 | 15 | export type ISelectionCallback = (selection: ISelectionRange) => void; 16 | 17 | /** 18 | * The CollaborativeSelectionManager controls the monitoring of local selection 19 | * / cursor positions and renders selections / cursors of collaborators. This 20 | * class will add an overlay to the DOM on top of the textarea to render 21 | * collaborators selections. 22 | */ 23 | export class CollaborativeSelectionManager { 24 | private readonly _collaborators: Map; 25 | private readonly _textElement: HTMLTextAreaElement; 26 | private readonly _overlayContainer: HTMLDivElement; 27 | private readonly _scroller: HTMLDivElement; 28 | private readonly _onSelection: ISelectionCallback; 29 | private _selectionAnchor: number; 30 | private _selectionTarget: number; 31 | 32 | /** 33 | * Creates a new [[CollaborativeSelectionManager]]. 34 | * 35 | * @param options The options that configure this instance. 36 | */ 37 | constructor(options: ICollaborativeSelectionManagerOptions) { 38 | this._collaborators = new Map(); 39 | this._textElement = options.control; 40 | 41 | // TODO handle the line height better. The issue here 42 | // is that the textarea-caret library can't handle 43 | // a non-number. 44 | const computed = window.getComputedStyle(this._textElement); 45 | if (computed.lineHeight === "normal") { 46 | throw new Error("Text areas must have a numeric line-height."); 47 | } 48 | 49 | this._onSelection = options.onSelectionChanged; 50 | 51 | this._selectionAnchor = this._textElement.selectionStart; 52 | this._selectionTarget = this._textElement.selectionEnd; 53 | 54 | this._overlayContainer = this._textElement.ownerDocument.createElement("div"); 55 | this._overlayContainer.className = "text-collab-ext"; 56 | this._textElement.parentElement.append(this._overlayContainer); 57 | 58 | this._scroller = this._textElement.ownerDocument.createElement("div"); 59 | this._scroller.className = "text-collab-ext-scroller"; 60 | this._overlayContainer.append(this._scroller); 61 | 62 | // Provide resize handling. After the mose down, we register for mouse 63 | // movement and check if we have resized. We then listen for a mouse up 64 | // to unregister. 65 | this._textElement.addEventListener("mousedown", () => { 66 | window.addEventListener("mousemove", this._onMouseMove); 67 | }); 68 | 69 | window.addEventListener("mouseup", () => { 70 | window.removeEventListener("mousemove", this._onMouseMove); 71 | this._checkResize(); 72 | }); 73 | 74 | this._textElement.addEventListener("scroll", () => this._updateScroller()); 75 | 76 | this._textElement.addEventListener("keydown", this._checkSelection); 77 | this._textElement.addEventListener("click", this._checkSelection); 78 | this._textElement.addEventListener("focus", this._checkSelection); 79 | this._textElement.addEventListener("blur", this._checkSelection); 80 | 81 | this.onResize(); 82 | } 83 | 84 | /** 85 | * Adds a remote collaborator to the textarea so that their cursor / 86 | * selection can be rendered. 87 | * 88 | * @param id A unique identifier for this collaborator. 89 | * @param label A text label to render over the cursor. 90 | * @param color The color to use for the cursor and selection. 91 | * @param selection The initial selection to render, if desired. 92 | * 93 | * @returns A [[CollaboratorSelection]] that can be used to control 94 | * the selection / cursor for this collaborator 95 | */ 96 | public addCollaborator(id: string, label: string, color: string, selection?: ISelectionRange): CollaboratorSelection { 97 | if (this._collaborators.has(id)) { 98 | throw new Error(`A collaborator with the specified id already exists: ${id}`); 99 | } 100 | 101 | const collaborator = new CollaboratorSelection(this._textElement, this._scroller, color, label, {margin: 5}); 102 | this._collaborators.set(id, collaborator); 103 | 104 | if (selection !== undefined && selection !== null) { 105 | collaborator.setSelection(selection); 106 | } 107 | 108 | return collaborator; 109 | } 110 | 111 | /** 112 | * Get the [[CollaboratorSelection]] for the specified collaborator. 113 | * 114 | * @param id The id of the collaborator to get the selection of. 115 | * 116 | * returns A [[CollaboratorSelection]] that can be used to control 117 | * the selection / cursor for this collaborator 118 | */ 119 | public getCollaborator(id: string): CollaboratorSelection { 120 | return this._collaborators.get(id); 121 | } 122 | 123 | /** 124 | * 125 | * @param id The id of the collaborator to remove. 126 | */ 127 | public removeCollaborator(id: string): void { 128 | const renderer = this._collaborators.get(id); 129 | if (renderer !== undefined) { 130 | renderer.clearSelection(); 131 | this._collaborators.delete(id); 132 | } else { 133 | throw new Error(`Unknown collaborator: ${id}`); 134 | } 135 | } 136 | 137 | /** 138 | * Gets the local users selection. 139 | */ 140 | public getSelection(): ISelectionRange { 141 | return { 142 | anchor: this._selectionAnchor, 143 | target: this._selectionTarget 144 | }; 145 | } 146 | 147 | /** 148 | * Shows collaborators selections, if hidden. 149 | */ 150 | public show(): void { 151 | this._overlayContainer.style.visibility = "visible"; 152 | } 153 | 154 | /** 155 | * Hides the collaborators selections, if shown. 156 | */ 157 | public hide(): void { 158 | this._overlayContainer.style.visibility = "hidden"; 159 | } 160 | 161 | /** 162 | * Removes the collaborator selection rendering from the DOM. 163 | */ 164 | public dispose(): void { 165 | this._overlayContainer.parentElement.removeChild(this._overlayContainer); 166 | } 167 | 168 | /** 169 | * Indicates that the textarea has been resized and the collaboration 170 | * overlay should be resized to match. 171 | */ 172 | public onResize(): void { 173 | const top = this._textElement.offsetTop; 174 | const left = this._textElement.offsetLeft; 175 | const height = this._textElement.offsetHeight; 176 | const width = this._textElement.offsetWidth; 177 | 178 | this._overlayContainer.style.top = top + "px"; 179 | this._overlayContainer.style.left = left + "px"; 180 | this._overlayContainer.style.height = height + "px"; 181 | this._overlayContainer.style.width = width + "px"; 182 | } 183 | 184 | public updateSelectionsOnInsert(index: number, value: string): void { 185 | this._collaborators.forEach((collaborator) => { 186 | const selection = collaborator.getSelection(); 187 | const anchor = IndexUtils.transformIndexOnInsert(selection.anchor, index, value); 188 | const target = IndexUtils.transformIndexOnInsert(selection.target, index, value); 189 | collaborator.setSelection({anchor, target}); 190 | }); 191 | } 192 | 193 | public updateSelectionsOnDelete(index: number, length: number): void { 194 | this._collaborators.forEach((collaborator) => { 195 | const selection = collaborator.getSelection(); 196 | const anchor = IndexUtils.transformIndexOnDelete(selection.anchor, index, length); 197 | const target = IndexUtils.transformIndexOnDelete(selection.target, index, length); 198 | collaborator.setSelection({anchor, target}); 199 | }); 200 | } 201 | 202 | private _checkSelection = () => { 203 | setTimeout(() => { 204 | const changed = this._textElement.selectionStart !== this._selectionAnchor || 205 | this._textElement.selectionEnd !== this._selectionTarget; 206 | if (changed) { 207 | if (this._selectionAnchor === this._textElement.selectionStart) { 208 | this._selectionAnchor = this._textElement.selectionStart; 209 | this._selectionTarget = this._textElement.selectionEnd; 210 | } else { 211 | this._selectionAnchor = this._textElement.selectionEnd; 212 | this._selectionTarget = this._textElement.selectionStart; 213 | } 214 | 215 | this._onSelection({ 216 | anchor: this._selectionAnchor, 217 | target: this._selectionTarget 218 | }); 219 | } 220 | }, 0); 221 | } 222 | 223 | private _onMouseMove = () => { 224 | this._checkResize(); 225 | this._checkSelection(); 226 | } 227 | 228 | private _checkResize = () => { 229 | if (this._textElement.offsetWidth !== this._overlayContainer.offsetWidth || 230 | this._textElement.offsetHeight !== this._overlayContainer.offsetHeight || 231 | this._textElement.offsetTop !== this._overlayContainer.offsetTop || 232 | this._textElement.offsetLeft !== this._overlayContainer.offsetLeft) { 233 | this.onResize(); 234 | this._collaborators.forEach(renderer => renderer.refresh()); 235 | } 236 | } 237 | 238 | private _updateScroller(): void { 239 | this._scroller.style.top = (this._textElement.scrollTop * -1) + "px"; 240 | this._scroller.style.left = (this._textElement.scrollLeft * -1) + "px"; 241 | } 242 | } -------------------------------------------------------------------------------- /src/ts/CollaborativeTextArea.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Convergence Labs, Inc. 3 | * 4 | * This file is part of the HTML Text Collaborative Extensions, which is 5 | * released under the terms of the MIT license. A copy of the MIT license 6 | * is usually provided as part of this source code package in the LICENCE 7 | * file. If it was not, please see 8 | */ 9 | 10 | import {CollaborativeSelectionManager} from "./CollaborativeSelectionManager"; 11 | import {TextInputManager} from "./TextInputManager"; 12 | import {ISelectionRange} from "./ISelectionRange"; 13 | import {ICollaborativeTextAreaOptions} from "./ICollaboratieTextAreaOptions"; 14 | 15 | 16 | /** 17 | * Adapts a plain HTMLTextAreaElement to add collaborative editing 18 | * capabilities. This class will add an overlay HTMLDivElement on 19 | * top of the HTMLTextAreaElement to render cursors and selection 20 | * of collaborators. This class also adds convenience API to 21 | * mutate the text area value and to get events / callbacks when 22 | * the value is changed by the user. Mutation methods and mutation 23 | * events are granular describing exactly how the value was changed. 24 | */ 25 | export class CollaborativeTextArea { 26 | private readonly _selectionManager: CollaborativeSelectionManager; 27 | private readonly _inputManager: TextInputManager; 28 | 29 | /** 30 | * Creates a new [[CollaborativeTextArea]]. 31 | * 32 | * @param options The options to configure this instance. 33 | */ 34 | constructor(options: ICollaborativeTextAreaOptions) { 35 | if (!options) { 36 | throw new Error("options must be defined."); 37 | } 38 | 39 | if (!options.control) { 40 | throw new Error("options.control must be defined."); 41 | } 42 | 43 | const control = options.control; 44 | const insertCallback = options.onInsert; 45 | const deleteCallback = options.onDelete; 46 | 47 | const onInsert = (index: number, value: string) => { 48 | this._selectionManager.updateSelectionsOnInsert(index, value); 49 | if (insertCallback) { 50 | insertCallback(index, value); 51 | } 52 | }; 53 | 54 | const onDelete = (index: number, length: number) => { 55 | this._selectionManager.updateSelectionsOnDelete(index, length); 56 | if (deleteCallback) { 57 | deleteCallback(index, length); 58 | } 59 | }; 60 | 61 | const onSelectionChanged = options.onSelectionChanged !== undefined ? 62 | options.onSelectionChanged : (_: ISelectionRange) => { 63 | // No-op 64 | }; 65 | 66 | this._inputManager = new TextInputManager({control, onInsert, onDelete}); 67 | this._selectionManager = new CollaborativeSelectionManager({control, onSelectionChanged}); 68 | } 69 | 70 | /** 71 | * Inserts text into the textarea. 72 | * 73 | * @param index The index at which to insert the text. 74 | * @param value The text to insert. 75 | */ 76 | public insertText(index: number, value: string): void { 77 | this._inputManager.insertText(index, value); 78 | this._selectionManager.updateSelectionsOnInsert(index, value); 79 | } 80 | 81 | /** 82 | * Deletes text from the textarea. 83 | * @param index The index at which to remove text. 84 | * @param length The number of characters to remove. 85 | */ 86 | public deleteText(index: number, length: number): void { 87 | this._inputManager.deleteText(index, length); 88 | this._selectionManager.updateSelectionsOnDelete(index, length); 89 | } 90 | 91 | /** 92 | * Sets the entire value of the textarea. 93 | * 94 | * @param value The value to set. 95 | */ 96 | public setText(value: string): void { 97 | this._inputManager.setText(value); 98 | } 99 | 100 | /** 101 | * Gets the current text of the textarea. 102 | */ 103 | public getText(): string { 104 | return this._inputManager.getText(); 105 | } 106 | 107 | /** 108 | * Gets the selection manager that controls local and collaborator 109 | * selections. 110 | */ 111 | public selectionManager(): CollaborativeSelectionManager { 112 | return this._selectionManager; 113 | } 114 | 115 | /** 116 | * Indicates that the textarea has been resized and the collaboration 117 | * overlay should be resized to match. 118 | */ 119 | public onResize(): void { 120 | this._selectionManager.onResize(); 121 | } 122 | } -------------------------------------------------------------------------------- /src/ts/CollaboratorSelection.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Convergence Labs, Inc. 3 | * 4 | * This file is part of the HTML Text Collaborative Extensions, which is 5 | * released under the terms of the MIT license. A copy of the MIT license 6 | * is usually provided as part of this source code package in the LICENCE 7 | * file. If it was not, please see 8 | */ 9 | 10 | import {ISelectionRange} from "./ISelectionRange"; 11 | import {ISelectionRow} from "./ISelectionRow"; 12 | import {SelectionComputer} from "./SelectionComputer"; 13 | 14 | // @ts-ignore 15 | import getCaretCoordinates from "textarea-caret"; 16 | import {ICollaboratorSelectionOptions} from "./ICollaboratorSelectionOptions"; 17 | 18 | interface ISelectionData { 19 | element: HTMLDivElement; 20 | rowData: ISelectionRow; 21 | } 22 | 23 | /** 24 | * This class represents a specific collaborators selection and controls 25 | * the rendering of the remote cursor / selection. 26 | */ 27 | export class CollaboratorSelection { 28 | private readonly _rows: ISelectionData[]; 29 | private readonly _cursorElement: HTMLDivElement; 30 | private readonly _tooltipElement: HTMLDivElement; 31 | private readonly _textInput: HTMLTextAreaElement; 32 | private readonly _container: HTMLDivElement; 33 | 34 | private _color: string; 35 | private _selection: ISelectionRange| null; 36 | private _cursor: number | null; 37 | private _label: string; 38 | private readonly _margin: number; 39 | private _tooltipTimeout: any; 40 | 41 | constructor( 42 | textInput: HTMLTextAreaElement, 43 | overlayContainer: HTMLDivElement, 44 | color: string, 45 | label: string, 46 | options: ICollaboratorSelectionOptions) { 47 | this._label = label; 48 | this._textInput = textInput; 49 | this._color = color; 50 | this._cursor = null; 51 | this._selection = null; 52 | this._rows = []; 53 | this._container = overlayContainer; 54 | 55 | options = options || {}; 56 | 57 | this._margin = options.margin || 5; 58 | 59 | this._tooltipTimeout = null; 60 | 61 | this._cursorElement = this._container.ownerDocument.createElement("div"); 62 | this._cursorElement.className = "collaborator-cursor"; 63 | this._cursorElement.style.backgroundColor = this._color; 64 | 65 | this._tooltipElement = this._container.ownerDocument.createElement("div"); 66 | this._tooltipElement.innerHTML = label; 67 | this._tooltipElement.className = "collaborator-cursor-tooltip"; 68 | this._tooltipElement.style.backgroundColor = this._color; 69 | 70 | this.hideCursorTooltip(); 71 | 72 | this.refresh(); 73 | } 74 | 75 | public showSelection(): void { 76 | this._rows.forEach(row => { 77 | row.element.style.visibility = "visible"; 78 | }); 79 | } 80 | 81 | public hideSelection(): void { 82 | this._rows.forEach(row => { 83 | row.element.style.visibility = "hidden"; 84 | }); 85 | } 86 | 87 | public showCursor(): void { 88 | this._cursorElement.style.visibility = "visible"; 89 | } 90 | 91 | public hideCursor(): void { 92 | this._cursorElement.style.visibility = "hidden"; 93 | } 94 | 95 | public showCursorToolTip(): void { 96 | this._clearToolTipTimeout(); 97 | this._tooltipElement.style.opacity = "1"; 98 | } 99 | 100 | public flashCursorToolTip(duration: number): void { 101 | this.showCursorToolTip(); 102 | this._clearToolTipTimeout(); 103 | this._tooltipTimeout = setTimeout(() => this.hideCursorTooltip(), duration * 1000); 104 | } 105 | 106 | public hideCursorTooltip(): void { 107 | this._clearToolTipTimeout(); 108 | this._tooltipElement.style.opacity = "0"; 109 | } 110 | 111 | private _clearToolTipTimeout(): void { 112 | if (this._tooltipTimeout !== null) { 113 | clearTimeout(this._tooltipTimeout); 114 | this._tooltipTimeout = null; 115 | } 116 | } 117 | 118 | public setColor(color: string): void { 119 | this._color = color; 120 | this._rows.forEach(row => { 121 | row.element.style.background = this._color; 122 | }); 123 | 124 | this._cursorElement.style.background = this._color; 125 | this._tooltipElement.style.background = this._color; 126 | } 127 | 128 | public setSelection(selection: ISelectionRange | null): void { 129 | if (selection === null) { 130 | this._cursor = null; 131 | this._selection = null; 132 | } else { 133 | this._cursor = selection.target; 134 | this._selection = {...selection}; 135 | } 136 | 137 | this.refresh(); 138 | } 139 | 140 | public getSelection(): ISelectionRange { 141 | return {...this._selection} 142 | } 143 | 144 | public clearSelection(): void { 145 | this.setSelection(null); 146 | } 147 | 148 | public refresh(): void { 149 | this._updateCursor(); 150 | this._updateSelection(); 151 | } 152 | 153 | private _updateCursor(): void { 154 | if (this._cursor === null && this._container.contains(this._cursorElement)) { 155 | this._container.removeChild(this._cursorElement); 156 | this._container.removeChild(this._tooltipElement); 157 | } else { 158 | if (!this._cursorElement.parentElement) { 159 | this._container.append(this._cursorElement); 160 | this._container.append(this._tooltipElement); 161 | } 162 | 163 | const cursorCoords = getCaretCoordinates(this._textInput, this._cursor); 164 | 165 | this._cursorElement.style.height = cursorCoords.height + "px"; 166 | this._cursorElement.style.top = cursorCoords.top + "px"; 167 | const cursorLeft = (cursorCoords.left - this._cursorElement.offsetWidth / 2); 168 | this._cursorElement.style.left = cursorLeft + "px"; 169 | 170 | let toolTipTop = cursorCoords.top - this._tooltipElement.offsetHeight; 171 | if (toolTipTop + this._container.offsetTop < this._margin) { 172 | toolTipTop = cursorCoords.top + cursorCoords.height; 173 | } 174 | 175 | let toolTipLeft = cursorLeft; 176 | if (toolTipLeft + this._tooltipElement.offsetWidth > this._container.offsetWidth - this._margin) { 177 | toolTipLeft = cursorLeft + this._cursorElement.offsetWidth - this._tooltipElement.offsetWidth; 178 | } 179 | 180 | this._tooltipElement.style.top = toolTipTop + "px"; 181 | this._tooltipElement.style.left = toolTipLeft + "px"; 182 | } 183 | } 184 | 185 | private _updateSelection(): void { 186 | if (this._selection === null) { 187 | this._rows.forEach(row => row.element.parentElement.removeChild(row.element)); 188 | this._rows.splice(0, this._rows.length); 189 | } else { 190 | 191 | let start; 192 | let end; 193 | 194 | if (this._selection.anchor > this._selection.target) { 195 | start = Number(this._selection.target); 196 | end = Number(this._selection.anchor); 197 | } else { 198 | start = Number(this._selection.anchor); 199 | end = Number(this._selection.target); 200 | } 201 | 202 | const newRows = SelectionComputer.calculateSelection(this._textInput, start, end); 203 | 204 | // Adjust size of rows as needed 205 | const delta = newRows.length - this._rows.length; 206 | 207 | if (delta > 0) { 208 | if (this._rows.length === 0 || this._rowsEqual(newRows[0], this._rows[0].rowData)) { 209 | this._addNewRows(delta, true); 210 | } else { 211 | this._addNewRows(delta, false); 212 | } 213 | } else if (delta < 0) { 214 | let removed = null; 215 | if (this._rowsEqual(newRows[0], this._rows[0].rowData)) { 216 | // Take from the target. 217 | removed = this._rows.splice(this._rows.length - 1 + delta, delta * -1); 218 | } else { 219 | removed = this._rows.splice(0, delta * -1); 220 | } 221 | 222 | removed.forEach(row => row.element.parentElement.removeChild(row.element)); 223 | } 224 | 225 | // Now compare each row and see if we need an update. 226 | newRows.forEach((newRowData: ISelectionRow, index: number) => { 227 | const row = this._rows[index]; 228 | this._updateRow(newRowData, row); 229 | }); 230 | } 231 | } 232 | 233 | private _addNewRows(count: number, append: boolean): void { 234 | for (let i = 0; i < count; i++) { 235 | const element = this._container.ownerDocument.createElement("div"); 236 | element.style.position = "absolute"; 237 | element.style.backgroundColor = this._color; 238 | element.style.opacity = "0.25"; 239 | this._container.append(element); 240 | const rowData = {height: 0, width: 0, top: 0, left: 0}; 241 | const newRow: ISelectionData = { 242 | element, 243 | rowData 244 | }; 245 | 246 | if (append) { 247 | this._rows.push(newRow); 248 | } else { 249 | this._rows.unshift(newRow); 250 | } 251 | } 252 | } 253 | 254 | private _rowsEqual(a: ISelectionRow, b: ISelectionRow): boolean { 255 | return a.height === b.height && 256 | a.width === b.width && 257 | a.top === b.top && 258 | a.left === b.left; 259 | } 260 | 261 | private _updateRow(newRowData: ISelectionRow, row: ISelectionData): void { 262 | if (newRowData.height !== row.rowData.height) { 263 | row.rowData.height = newRowData.height; 264 | row.element.style.height = `${newRowData.height}px`; 265 | } 266 | 267 | if (newRowData.width !== row.rowData.width) { 268 | row.rowData.width = newRowData.width; 269 | row.element.style.width = `${newRowData.width}px`; 270 | } 271 | 272 | if (newRowData.top !== row.rowData.top) { 273 | row.rowData.top = newRowData.top; 274 | row.element.style.top = `${newRowData.top}px`; 275 | } 276 | 277 | if (newRowData.left !== row.rowData.left) { 278 | row.rowData.left = newRowData.left; 279 | row.element.style.left = `${newRowData.left}px`; 280 | } 281 | } 282 | } -------------------------------------------------------------------------------- /src/ts/ICollaboratieTextAreaOptions.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Convergence Labs, Inc. 3 | * 4 | * This file is part of the HTML Text Collaborative Extensions, which is 5 | * released under the terms of the MIT license. A copy of the MIT license 6 | * is usually provided as part of this source code package in the LICENCE 7 | * file. If it was not, please see 8 | */ 9 | 10 | import {ISelectionCallback} from "./CollaborativeSelectionManager"; 11 | 12 | /** 13 | * Represents the options that can be passed to the 14 | * CollaborativeTextArea class. 15 | */ 16 | export interface ICollaborativeTextAreaOptions { 17 | /** 18 | * The HTML TextArea to adapt for collaborative editing. 19 | */ 20 | control: HTMLTextAreaElement; 21 | 22 | /** 23 | * A callback to call when text is inserted into the textarea. 24 | * 25 | * @param index The index at which the text was inserted. 26 | * @param value The text that was inserted. 27 | */ 28 | onInsert: (index: number, value: string) => void; 29 | 30 | /** 31 | * A callback to call when text is removed from the textarea. 32 | * @param index The index at which the text was removed. 33 | * 34 | * @param length The length of the text that was removed. 35 | */ 36 | onDelete: (index: number, length: number) => void; 37 | 38 | /** 39 | * A callback that will be called when the local users selection / 40 | * cursor position has changed. 41 | */ 42 | onSelectionChanged: ISelectionCallback; 43 | } 44 | -------------------------------------------------------------------------------- /src/ts/ICollaborativeSelectionManagerOptions.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Convergence Labs, Inc. 3 | * 4 | * This file is part of the HTML Text Collaborative Extensions, which is 5 | * released under the terms of the MIT license. A copy of the MIT license 6 | * is usually provided as part of this source code package in the LICENCE 7 | * file. If it was not, please see 8 | */ 9 | 10 | import {ISelectionCallback} from "./CollaborativeSelectionManager"; 11 | 12 | export interface ICollaborativeSelectionManagerOptions { 13 | control: HTMLTextAreaElement; 14 | onSelectionChanged: ISelectionCallback; 15 | } -------------------------------------------------------------------------------- /src/ts/ICollaboratorSelectionOptions.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Convergence Labs, Inc. 3 | * 4 | * This file is part of the HTML Text Collaborative Extensions, which is 5 | * released under the terms of the MIT license. A copy of the MIT license 6 | * is usually provided as part of this source code package in the LICENCE 7 | * file. If it was not, please see 8 | */ 9 | 10 | export interface ICollaboratorSelectionOptions { 11 | margin?: number; 12 | } 13 | -------------------------------------------------------------------------------- /src/ts/ICursorCoordinates.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Convergence Labs, Inc. 3 | * 4 | * This file is part of the HTML Text Collaborative Extensions, which is 5 | * released under the terms of the MIT license. A copy of the MIT license 6 | * is usually provided as part of this source code package in the LICENCE 7 | * file. If it was not, please see 8 | */ 9 | 10 | /** 11 | * Represents the coordinates of the cursor within the textarea. 12 | */ 13 | export interface ICursorCoordinates { 14 | /** 15 | * The distance in pixels from the top of the textarea. 16 | */ 17 | top: number; 18 | 19 | /** 20 | * The distance in pixels from the left of the textarea. 21 | */ 22 | left: number; 23 | 24 | /** 25 | * The height in pixels of the cursor. 26 | */ 27 | height: number; 28 | } 29 | -------------------------------------------------------------------------------- /src/ts/ISelectionRange.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Convergence Labs, Inc. 3 | * 4 | * This file is part of the HTML Text Collaborative Extensions, which is 5 | * released under the terms of the MIT license. A copy of the MIT license 6 | * is usually provided as part of this source code package in the LICENCE 7 | * file. If it was not, please see 8 | */ 9 | 10 | /** 11 | * A selection range describe in the linear index address space of 12 | * the text in the textarea. 13 | */ 14 | export interface ISelectionRange { 15 | /** 16 | * The index of the anchor of the selection. 17 | */ 18 | anchor: number; 19 | 20 | /** 21 | * The index of the target of the selection. This is 22 | * where the cursor will be. 23 | */ 24 | target: number; 25 | } 26 | -------------------------------------------------------------------------------- /src/ts/ISelectionRow.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Convergence Labs, Inc. 3 | * 4 | * This file is part of the HTML Text Collaborative Extensions, which is 5 | * released under the terms of the MIT license. A copy of the MIT license 6 | * is usually provided as part of this source code package in the LICENCE 7 | * file. If it was not, please see 8 | */ 9 | 10 | /** 11 | * Represents a highlighted selection row's rendering information 12 | */ 13 | export interface ISelectionRow { 14 | top: number; 15 | left: number; 16 | height: number; 17 | width: number; 18 | } 19 | -------------------------------------------------------------------------------- /src/ts/ITextInputManagerOptions.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Convergence Labs, Inc. 3 | * 4 | * This file is part of the HTML Text Collaborative Extensions, which is 5 | * released under the terms of the MIT license. A copy of the MIT license 6 | * is usually provided as part of this source code package in the LICENCE 7 | * file. If it was not, please see 8 | */ 9 | 10 | export interface ITextInputManagerOptions { 11 | control: HTMLTextAreaElement; 12 | onInsert: (index: number, value: string) => void; 13 | onDelete: (index: number, length: number) => void; 14 | } 15 | -------------------------------------------------------------------------------- /src/ts/IndexUtils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Convergence Labs, Inc. 3 | * 4 | * This file is part of the HTML Text Collaborative Extensions, which is 5 | * released under the terms of the MIT license. A copy of the MIT license 6 | * is usually provided as part of this source code package in the LICENCE 7 | * file. If it was not, please see 8 | */ 9 | 10 | export class IndexUtils { 11 | public static transformIndexOnInsert(index: number, insertIndex: number, value: string): number { 12 | if (insertIndex <= index) { 13 | return index + value.length; 14 | } 15 | return index; 16 | } 17 | 18 | public static transformIndexOnDelete(index: number, deleteIndex: number, length: number): number { 19 | if (index > deleteIndex) { 20 | return index - Math.min(index - deleteIndex, length); 21 | } 22 | return index; 23 | } 24 | } -------------------------------------------------------------------------------- /src/ts/SelectionComputer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Convergence Labs, Inc. 3 | * 4 | * This file is part of the HTML Text Collaborative Extensions, which is 5 | * released under the terms of the MIT license. A copy of the MIT license 6 | * is usually provided as part of this source code package in the LICENCE 7 | * file. If it was not, please see 8 | */ 9 | 10 | import {ISelectionRow} from "./ISelectionRow"; 11 | import {ICursorCoordinates} from "./ICursorCoordinates"; 12 | 13 | // @ts-ignore 14 | import getCaretCoordinates from "textarea-caret"; 15 | 16 | 17 | /** 18 | * Computes the dimensions of the text selection. Each line in the textarea has its own 19 | * selection dimensions, which are intended to be used to render a div with the specified 20 | * position, dimensions and background color. 21 | * 22 | * This has only been tested on a textarea, but should be able to be adapted to be used 23 | * in other HTML form elements. 24 | * 25 | * TODO unit test, this is pretty brittle 26 | */ 27 | export class SelectionComputer { 28 | 29 | public static calculateSelection(element: HTMLTextAreaElement, start: number, end: number): ISelectionRow[] { 30 | const computer = new SelectionComputer(element, start, end); 31 | return computer.selectionRows; 32 | } 33 | 34 | // The calculated styles for each row. 35 | private readonly selectionRows: ISelectionRow[]; 36 | 37 | private readonly startCoordinates: ICursorCoordinates; 38 | private readonly endCoordinates: ICursorCoordinates; 39 | private readonly lineHeight: number; 40 | private readonly elementPaddingLeft: number; 41 | private readonly elementPaddingRight: number; 42 | private readonly elementPaddingX: number; 43 | 44 | private constructor( 45 | private element: HTMLTextAreaElement, 46 | private start: number, 47 | private end: number) { 48 | 49 | this.startCoordinates = getCaretCoordinates(element, start); 50 | this.endCoordinates = getCaretCoordinates(element, end); 51 | this.lineHeight = this.startCoordinates.height; 52 | this.elementPaddingLeft = parseFloat(element.style.paddingLeft) || 0; 53 | this.elementPaddingRight = parseFloat(element.style.paddingRight) || 0; 54 | this.elementPaddingX = this.elementPaddingLeft + this.elementPaddingRight; 55 | 56 | this.selectionRows = []; 57 | 58 | // Figure out whether this selection spans more than one "row", as determined by 59 | // the presence of a newline character. The computation of single line selections 60 | // is slightly different than for multiple line selections. 61 | const selectedText = element.value.substr(start, end - start); 62 | if (selectedText.indexOf('\n') < 0) { 63 | this.appendSingleLineSelection(this.startCoordinates, this.endCoordinates); 64 | } else { 65 | this.buildMultiRowSelection(); 66 | } 67 | } 68 | 69 | private appendSingleLineSelection(startCoordinates: ICursorCoordinates, endCoordinates: ICursorCoordinates) { 70 | this.selectionRows.push(...this.buildSingleLineSelection(startCoordinates, endCoordinates)); 71 | } 72 | 73 | private buildSingleLineSelection(startCoordinates: ICursorCoordinates, endCoordinates: ICursorCoordinates): ISelectionRow[] { 74 | // does this line wrap? If not, we can just calculate the row selection based on 75 | // the provided coordinates. 76 | if (startCoordinates.top === endCoordinates.top) { 77 | return [{ 78 | width: endCoordinates.left - startCoordinates.left, 79 | top: startCoordinates.top, 80 | left: startCoordinates.left, 81 | height: this.lineHeight 82 | }]; 83 | } else { 84 | return this.buildWrappedLineSelections(startCoordinates, endCoordinates); 85 | } 86 | } 87 | 88 | /** 89 | * Wrapped lines have a more complex computation since we have to create multiple 90 | * rows. 91 | * 92 | * @param startCoordinates 93 | * @param endCoordinates 94 | */ 95 | private buildWrappedLineSelections(startCoordinates: ICursorCoordinates, endCoordinates: ICursorCoordinates): ISelectionRow[] { 96 | const rows: ISelectionRow[] = []; 97 | // the first line just goes the full width of the textarea 98 | rows.push({ 99 | width: this.element.scrollWidth - this.elementPaddingRight - startCoordinates.left, 100 | top: startCoordinates.top, 101 | left: startCoordinates.left, 102 | height: this.lineHeight 103 | }); 104 | 105 | // If the selection contains one or more rows that span the entire textarea, 106 | // calculate a single selection element, which may actually span multiple rows, 107 | // but fills the width of the textarea. 108 | if (endCoordinates.top > startCoordinates.top + this.lineHeight) { 109 | rows.push({ 110 | width: this.element.scrollWidth - this.elementPaddingX, 111 | left: this.elementPaddingLeft, 112 | top: startCoordinates.top + this.lineHeight, 113 | height: endCoordinates.top - startCoordinates.top - this.lineHeight 114 | }); 115 | } 116 | 117 | // The last line starts at the left edge of the textarea and doesn't span the 118 | // entire width of the textarea 119 | rows.push({ 120 | width: endCoordinates.left - this.elementPaddingLeft, 121 | top: endCoordinates.top, 122 | left: this.elementPaddingLeft, 123 | height: this.lineHeight 124 | }); 125 | return rows; 126 | } 127 | 128 | private buildMultiRowSelection() { 129 | let currentCoordinates = this.startCoordinates; 130 | let currentIndex = +this.start; 131 | 132 | // build one or more selection elements for each row (as determined by newline 133 | // characters) 134 | while (currentCoordinates.top < this.endCoordinates.top) { 135 | const nextLineBreakPosition = this.element.value.indexOf('\n', currentIndex); 136 | let endOfLinePosition = this.element.value.length; 137 | if (nextLineBreakPosition >= 0) { 138 | endOfLinePosition = nextLineBreakPosition; 139 | } 140 | if (endOfLinePosition > this.end) { 141 | endOfLinePosition = this.end; 142 | } 143 | const endOfLineCoordinates = getCaretCoordinates(this.element, endOfLinePosition); 144 | 145 | // This "single line" may actually wrap multiple lines of the textarea 146 | this.appendSingleLineSelection(currentCoordinates, endOfLineCoordinates); 147 | 148 | currentIndex = endOfLinePosition + 1; 149 | currentCoordinates = getCaretCoordinates(this.element, currentIndex); 150 | } 151 | if (currentIndex < this.end) { 152 | const lastLine = { 153 | width: this.endCoordinates.left - currentCoordinates.left, 154 | top: currentCoordinates.top, 155 | left: currentCoordinates.left, 156 | height: this.lineHeight 157 | }; 158 | this.selectionRows.push(lastLine); 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/ts/TextInputManager.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Convergence Labs, Inc. 3 | * 4 | * This file is part of the HTML Text Collaborative Extensions, which is 5 | * released under the terms of the MIT license. A copy of the MIT license 6 | * is usually provided as part of this source code package in the LICENCE 7 | * file. If it was not, please see 8 | */ 9 | 10 | // @ts-ignore 11 | import StringChangeDetector from "@convergence/string-change-detector"; 12 | import {IndexUtils} from "./IndexUtils"; 13 | import {ITextInputManagerOptions} from "./ITextInputManagerOptions"; 14 | 15 | export class TextInputManager { 16 | 17 | private readonly _control: HTMLTextAreaElement; 18 | private readonly _onLocalInsert: (index: number, value: string) => void; 19 | private readonly _onLocalDelete: (index: number, length: number) => void; 20 | private _changeDetector: StringChangeDetector; 21 | 22 | /** 23 | * 24 | * @param options 25 | */ 26 | constructor(options: ITextInputManagerOptions) { 27 | this._control = options.control; 28 | this._onLocalInsert = options.onInsert; 29 | this._onLocalDelete = options.onDelete; 30 | this._changeDetector = null; 31 | 32 | this.bind(); 33 | } 34 | 35 | bind(): void { 36 | this._changeDetector = new StringChangeDetector({ 37 | value: this._control.value, 38 | onInsert: this._onLocalInsert, 39 | onRemove: this._onLocalDelete 40 | }); 41 | 42 | this._control.addEventListener("input", this._onLocalInput); 43 | } 44 | 45 | unbind(): void { 46 | this._control.removeEventListener("input", this._onLocalInput); 47 | this._changeDetector = null; 48 | } 49 | 50 | insertText(index: number, value: string): void { 51 | this._assertBound(); 52 | const {start, end} = this._getSelection(); 53 | const xStart = IndexUtils.transformIndexOnInsert(start, index, value); 54 | const xEnd = IndexUtils.transformIndexOnInsert(end, index, value); 55 | this._changeDetector.insertText(index, value); 56 | this._updateControl(); 57 | this._setTextSelection(xStart, xEnd); 58 | } 59 | 60 | deleteText(index: number, length: number): void { 61 | this._assertBound(); 62 | const {start, end} = this._getSelection(); 63 | const xStart = IndexUtils.transformIndexOnDelete(start, index, length); 64 | const xEnd = IndexUtils.transformIndexOnDelete(end, index, length); 65 | this._changeDetector.removeText(index, length); 66 | this._updateControl(); 67 | this._setTextSelection(xStart, xEnd); 68 | } 69 | 70 | setText(value: string): void { 71 | this._assertBound(); 72 | this._changeDetector.setValue(value); 73 | this._updateControl(); 74 | this._setTextSelection(0, 0); 75 | } 76 | 77 | getText(): string { 78 | return this._control.value; 79 | } 80 | 81 | private _updateControl(): void { 82 | this._control.value = this._changeDetector.getValue(); 83 | } 84 | 85 | private _onLocalInput = () => { 86 | this._changeDetector.processNewValue(this._control.value); 87 | } 88 | 89 | private _assertBound(): void { 90 | if (this._changeDetector === null) { 91 | throw new Error("The TextInputManager is not bound."); 92 | } 93 | } 94 | 95 | private _getSelection(): {start: number, end: number} { 96 | return {'start': this._control.selectionStart, 'end': this._control.selectionEnd}; 97 | } 98 | 99 | private _setTextSelection(start: number, end: number): void { 100 | this._control.setSelectionRange(start, end); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/ts/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Convergence Labs, Inc. 3 | * 4 | * This file is part of the HTML Text Collaborative Extensions, which is 5 | * released under the terms of the MIT license. A copy of the MIT license 6 | * is usually provided as part of this source code package in the LICENCE 7 | * file. If it was not, please see 8 | */ 9 | 10 | export * from "./ICollaborativeSelectionManagerOptions"; 11 | export * from "./CollaborativeSelectionManager"; 12 | 13 | export * from "./ICollaboratorSelectionOptions" 14 | export * from "./CollaboratorSelection"; 15 | 16 | export * from "./ITextInputManagerOptions"; 17 | export * from "./TextInputManager"; 18 | 19 | export * from "./ICollaboratieTextAreaOptions"; 20 | export * from "./CollaborativeTextArea"; 21 | 22 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | } --------------------------------------------------------------------------------