├── .github ├── CHANGELOG.deprecated.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── npm-deploy.yml ├── .gitignore ├── .husky └── pre-commit ├── CHANGELOG.md ├── LICENSE ├── README.md ├── configs ├── firebase.json.sample ├── tsconfig.json ├── tsconfig.module.json ├── webpack.config.js └── webpack.dev.config.js ├── examples ├── README.md ├── firepad-monaco-example.ts ├── firepad-userlist.css ├── firepad-userlist.js ├── index.html ├── monaco.html ├── security │ ├── README.md │ ├── secret-url.json │ └── validate-auth.json └── userlist.html ├── package.json ├── scripts └── tag-and-version ├── src ├── client.ts ├── cursor-widget-controller.ts ├── cursor-widget.ts ├── cursor.ts ├── database-adapter.ts ├── editor-adapter.ts ├── editor-client.ts ├── emitter.ts ├── firebase-adapter.ts ├── firepad-classic.ts ├── firepad-monaco.ts ├── firepad.ts ├── firestore-adapter.ts ├── index.ts ├── monaco-adapter.ts ├── operation-meta.ts ├── remote-client.ts ├── text-op.ts ├── text-operation.ts ├── undo-manager.ts ├── utils.ts └── wrapped-operation.ts ├── test ├── __snapshots__ │ ├── database-adapter.spec.ts.snap │ └── editor-adapter.spec.ts.snap ├── client.spec.ts ├── cursor.spec.ts ├── database-adapter.spec.ts ├── editor-adapter.spec.ts ├── editor-client.spec.ts ├── emitter.spec.ts ├── factory │ ├── database-adapter.factory.ts │ ├── editor-adapter.factory.ts │ ├── editor-client.factory.ts │ ├── factory-utils.ts │ ├── index.ts │ └── monaco-editor.factory.ts ├── firepad-monaco.spec.ts ├── firepad.spec.ts ├── operation-meta.spec.ts ├── remote-client.spec.ts ├── text-op.spec.ts ├── text-operation.spec.ts ├── undo-manager.spec.ts └── wrapped-operation.spec.ts └── yarn.lock /.github/CHANGELOG.deprecated.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v1.5.28 [#22](https://github.com/interviewstreet/firepad-x/pull/22) 4 | ### Fixes - 5 | - More `null` check for cursor before invoking `equals` method. 6 | 7 | ## v1.5.27 8 | ### Fixes - 9 | - Add `null` check for cursor before invoking `equals` method. 10 | 11 | ## v1.5.26 [#21](https://github.com/interviewstreet/firepad-x/pull/21) 12 | ### Fixes - 13 | - Sync Cursor with timeout in case of delayed initialisation. 14 | - Persist Cursor information even after disposition. 15 | - Maintain `sync` state on Cursor in Editor Client. 16 | - Trigger `error` event if a valid Edit Operation transaction fails any reason other than client disconnection. 17 | - Make default options of Firepad Constructor functions to allow lazy evaluation. 18 | 19 | ## v1.5.25 [#20](https://github.com/interviewstreet/firepad-x/pull/20) 20 | ### Fixes - 21 | - Remove Data Type Validation for Operation Actor (`op.a`) so that number can used as User ID. 22 | 23 | ## v1.5.24 [#19](https://github.com/interviewstreet/firepad-x/pull/19) 24 | ### Fixes - 25 | - Send actual operation in strigified version on event-bus for `undo` and `redo` operation. 26 | 27 | ## v1.5.23 [#18](https://github.com/interviewstreet/firepad-x/pull/18) 28 | ### Fixes - 29 | - Stop selecting text after first initialisation. Move cursor to begining after `setText` call. 30 | 31 | ## v1.5.22 [#17](https://github.com/interviewstreet/firepad-x/pull/17) 32 | ### Improvements - 33 | - Added Undo annd Redo event to EditorClient to assign event listener. 34 | 35 | ### Changes - 36 | - Moved Firebase into peer dependency. 37 | 38 | ## v1.5.21 [#16](https://github.com/interviewstreet/firepad-x/pull/16) 39 | ### Fixes - 40 | - Downgrade Firebase to 7.12 to avoid issues with Database. 41 | 42 | ## v1.5.20 [#15](https://github.com/interviewstreet/firepad-x/pull/15) 43 | ### Fixes - 44 | - Model Change Event Handling when no Model Content has changed. 45 | 46 | ### Improvements - 47 | - Move `jsdom` to devDependency of the project. 48 | - Improve build step to optimize output chunk. 49 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing | Firepad 2 | 3 | Thank you for contributing to the Firebase community! 4 | 5 | - [Have a usage question?](#question) 6 | - [Think you found a bug?](#issue) 7 | - [Have a feature request?](#feature) 8 | - [Want to submit a pull request?](#submit) 9 | - [Need to get set up locally?](#local-setup) 10 | 11 | ## Have a usage question? 12 | 13 | We get lots of those and we love helping you, but GitHub is not the best place for them. Issues 14 | which just ask about usage will be closed. Here are some resources to get help: 15 | 16 | - Go through the [documentation](https://firepad.io/docs/) 17 | - Try out some [examples](../examples/README.md) 18 | 19 | If the official documentation doesn't help, try asking a question on the 20 | [Firebase Google Group](https://groups.google.com/forum/#!forum/firebase-talk) or one of our 21 | other [official support channels](https://firebase.google.com/support/). 22 | 23 | **Please avoid double posting across multiple channels!** 24 | 25 | ## Think you found a bug? 26 | 27 | Yeah, we're definitely not perfect! 28 | 29 | Search through [old issues](https://github.com/interviewstreet/firepad-x/issues) before submitting a new 30 | issue as your question may have already been answered. 31 | 32 | If your issue appears to be a bug, and hasn't been reported, 33 | [open a new issue](https://github.com/interviewstreet/firepad-x/issues/new). Please use the provided bug 34 | report template and include a minimal repro. 35 | 36 | If you are up to the challenge, [submit a pull request](#submit) with a fix! 37 | 38 | ## Have a feature request? 39 | 40 | Great, we love hearing how we can improve our products! After making sure someone hasn't already 41 | requested the feature in the [existing issues](https://github.com/interviewstreet/firepad-x/issues), go 42 | ahead and [open a new issue](https://github.com/interviewstreet/firepad-x/issues/new). Feel free to remove 43 | the bug report template and instead provide an explanation of your feature request. Provide code 44 | samples if applicable. Try to think about what it will allow you to do that you can't do today? How 45 | will it make current workarounds straightforward? What potential bugs and edge cases does it help to 46 | avoid? 47 | 48 | ## Want to submit a pull request? 49 | 50 | Sweet, we'd love to accept your contribution! [Open a new pull request](https://github.com/interviewstreet/firepad-x/pull/new/master) 51 | and fill out the provided form. 52 | 53 | **If you want to implement a new feature, please open an issue with a proposal first so that we can 54 | figure out if the feature makes sense and how it will work.** 55 | 56 | Make sure your changes pass our linter and the tests all pass on your local machine. We've hooked 57 | up this repo with continuous integration to double check those things for you. 58 | 59 | Most non-trivial changes should include some extra test coverage. If you aren't sure how to add 60 | tests, feel free to submit regardless and ask us for some advice. 61 | 62 | ## Need to get set up locally? 63 | 64 | If you'd like to contribute to Firepad, you'll need to do the following to get your environment set up. 65 | 66 | ### Install Dependencies 67 | 68 | ```bash 69 | $ git clone https://github.com/interviewstreet/firepad-x.git 70 | $ cd firepad # go to the firepad directory 71 | 72 | $ npm install -g yarn # install yarn globally 73 | 74 | $ yarn # install local npm build / test dependencies 75 | ``` 76 | 77 | ### Start Dev Server 78 | 79 | ```bash 80 | $ cp configs/firebase.sample.json configs/firebase.json # copy dummy config file 81 | 82 | $ vi configs/firebase.json # update configuration 83 | 84 | $ yarn start # start webpack server 85 | ``` 86 | 87 | ### Lint, Build, and Test 88 | 89 | ```bash 90 | $ yarn lint # run prettier 91 | 92 | $ yarn test # run jest test suites 93 | 94 | $ yarn build # produces output bundles 95 | ``` 96 | 97 | ### Output Directories 98 | 99 | 1. `dist` - Single chunk bundle to directly use in Browser in a ` 37 | 38 | 39 | -------------------------------------------------------------------------------- /examples/security/README.md: -------------------------------------------------------------------------------- 1 | This directory contains examples of how you could set up your security for Firepad. They're not exhaustive. 2 | 3 | # secret-url.json 4 | 5 | Example demonstrating how to secure Firepad by using secret URLs (you need the secret URL to be able to 6 | find / modify the Firebase data. 7 | 8 | # validate-auth.json 9 | 10 | Example demonstrating how to require that users be authenticated to read and write to the Firepad. Also ensures that 11 | all edits correctly include the authenticated user's id (i.e. prevent users from spoofing each other). 12 | -------------------------------------------------------------------------------- /examples/security/secret-url.json: -------------------------------------------------------------------------------- 1 | /* Example assumes you'll store the data for each Firepad at 2 | https://.firebaseio.com// 3 | */ 4 | { 5 | "rules": { 6 | "$secretid": { 7 | "history": { 8 | ".read": true, 9 | "$revision": { 10 | /* Prevent overwriting existing revisions. */ 11 | ".write": "data.val() === null" 12 | } 13 | }, 14 | "checkpoint": { 15 | ".read": true, 16 | /* Ensure author of checkpoint is the same as the author of the revision they're checkpointing. */ 17 | ".write": "root.child($secretid).child('history').child(newData.child('id').val()).child('a').val() === newData.child('a').val()", 18 | ".validate": "newData.hasChildren(['a', 'o', 'id'])" 19 | }, 20 | "users": { 21 | ".read": true, 22 | "$user": { 23 | ".write": true 24 | } 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/security/validate-auth.json: -------------------------------------------------------------------------------- 1 | /* Example assumes your Firepad is at the root of your Firebase Database. */ 2 | { 3 | "rules": { 4 | "history": { 5 | ".read": "auth != null", 6 | "$revision": { 7 | /* Allow writing a revision as long as it doesn't already exist and you write your auth.uid as the 'a' field. */ 8 | ".write": "data.val() === null && newData.child('a').val() === auth.uid" 9 | } 10 | }, 11 | "users": { 12 | ".read": "auth != null", 13 | "$userid": { 14 | /* You may freely modify your own user info. */ 15 | ".write": "$userid === auth.uid" 16 | } 17 | }, 18 | "checkpoint": { 19 | ".read": "auth != null", 20 | /* You may write a checkpoint as long as you're writing your auth.uid as the 'a' field and you 21 | also wrote the revision that you're checkpointing. */ 22 | ".write": "newData.child('a').val() === auth.uid && root.child('history').child(newData.child('id').val()).child('a').val() === auth.uid" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/userlist.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 16 | 20 | 21 | 22 | 24 | 25 | 26 | 27 | 54 | 55 | 56 | 57 |
58 |
59 | 60 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hackerrank/firepad", 3 | "description": "Collaborative text editing powered by Firebase", 4 | "version": "0.8.5", 5 | "author": { 6 | "email": "bprogyan@gmail.com", 7 | "name": "Progyan Bhattacharya", 8 | "url": "https://github.com/Progyan1997" 9 | }, 10 | "contributors": [ 11 | "Michael Lehenbauer ", 12 | "Brijesh Bittu ", 13 | "Nishchay Kaushik ", 14 | "Mohanasundar " 15 | ], 16 | "maintainers": [ 17 | { 18 | "email": "developers@hackerrank.com", 19 | "name": "HackerRank", 20 | "url": "https://hackerrank.com" 21 | }, 22 | "Firebase (https://firebase.google.com/)" 23 | ], 24 | "homepage": "http://www.firepad.io/", 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/interviewstreet/firepad-x.git" 28 | }, 29 | "bugs": { 30 | "url": "https://github.com/interviewstreet/firepad-x/issues" 31 | }, 32 | "license": "MIT", 33 | "keywords": [ 34 | "text", 35 | "word", 36 | "editor", 37 | "monaco", 38 | "firebase", 39 | "realtime", 40 | "collaborative" 41 | ], 42 | "main": "dist/firepad.min.js", 43 | "module": "es/index.js", 44 | "types": "types", 45 | "files": [ 46 | "dist/**", 47 | "es/**", 48 | "types/**", 49 | "LICENSE", 50 | "package.json", 51 | "yarn.lock" 52 | ], 53 | "engines": { 54 | "node": ">= 10.17.0" 55 | }, 56 | "peerDependencies": { 57 | "firebase": "8.10.1", 58 | "monaco-editor": "0.18.1" 59 | }, 60 | "dependencies": { 61 | "uuid": "8.3.2" 62 | }, 63 | "devDependencies": { 64 | "@babel/core": "7.13.16", 65 | "@babel/plugin-proposal-class-properties": "7.13.0", 66 | "@babel/plugin-proposal-object-rest-spread": "7.13.8", 67 | "@babel/preset-env": "7.13.15", 68 | "@babel/preset-typescript": "7.13.0", 69 | "@types/jest": "26.0.23", 70 | "@types/uuid": "8.3.0", 71 | "babel-loader": "8.2.2", 72 | "check-yarn-lock": "0.2.1", 73 | "child-process-promise": "2.2.1", 74 | "core-js": "3.11.0", 75 | "css-loader": "5.2.4", 76 | "firebase": "8.10.1", 77 | "git-format-staged": "2.1.1", 78 | "husky": "^7.0.0", 79 | "jest": "27.0.3", 80 | "monaco-editor": "0.18.1", 81 | "monaco-editor-webpack-plugin": "1.7.0", 82 | "prettier": "2.2.1", 83 | "rimraf": "3.0.2", 84 | "source-map-loader": "2.0.1", 85 | "style-loader": "2.0.0", 86 | "ts-jest": "27.0.3", 87 | "typescript": "4.2.4", 88 | "webpack": "5.34.0", 89 | "webpack-cli": "4.6.0", 90 | "webpack-dev-server": "3.11.2" 91 | }, 92 | "scripts": { 93 | "prepare": "husky install", 94 | "start": "webpack serve --hot --progress --mode development --port 9000 --host 0.0.0.0 --config configs/webpack.dev.config.js", 95 | "start:prod": "webpack serve --hot --progress --mode production --port 9000 --host 0.0.0.0 --config configs/webpack.dev.config.js", 96 | "clean": "rimraf dist es lib types", 97 | "prebuild": "yarn clean", 98 | "build": "yarn build:esm && yarn build:cjs && yarn build:dist", 99 | "build:dist": "NODE_ENV=production webpack --config configs/webpack.config.js", 100 | "build:esm": "tsc -p configs/tsconfig.module.json", 101 | "build:cjs": "tsc -p configs/tsconfig.json", 102 | "lint": "yarn lint:lockfile && yarn lint:prettier", 103 | "lint:prettier": "prettier --write configs examples src test", 104 | "lint:lockfile": "check-yarn-lock", 105 | "release": "./scripts/tag-and-version", 106 | "test": "TEST_ENV=true jest --colors", 107 | "test:ci": "yarn test --ci --silent", 108 | "test:watch": "yarn test --watch" 109 | }, 110 | "husky": { 111 | "hooks": { 112 | "pre-commit": "git-format-staged -f \"yarn lint\" ." 113 | } 114 | }, 115 | "jest": { 116 | "collectCoverage": true, 117 | "collectCoverageFrom": [ 118 | "**/src/**", 119 | "!**/src/firepad-classic.ts", 120 | "!**/src/index.ts", 121 | "!**/src/utils.ts" 122 | ], 123 | "preset": "ts-jest", 124 | "testEnvironment": "node" 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /scripts/tag-and-version: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require("fs"); 4 | const path = require("path"); 5 | const fetch = require("node-fetch"); 6 | const exec = require("child-process-promise").exec; 7 | 8 | const packageJSON = require("../package.json"); 9 | 10 | const Headers = { 11 | Authorization: `token ${process.env.GITHUB_TOKEN}`, 12 | "User-Agent": "HackerRank internal tools", 13 | }; 14 | 15 | const Tags = Object.freeze({ 16 | Beta: "beta", 17 | Stable: "latest", 18 | }); 19 | 20 | const ExitCodes = Object.freeze({ 21 | Success: 0, 22 | }); 23 | 24 | const BetaVersionRegex = /^[0-9]+\.[0-9]+\.[0-9]+\-[a-zA-z0-9.-]+$/; 25 | const StableVersionRegex = /^[0-9]+\.[0-9]+\.[0-9]+$/; 26 | 27 | /** 28 | * Tests whether or not we are in `main` branch. 29 | * @returns True if the current branch is `main`, else False. 30 | */ 31 | function isMasterBranch() { 32 | return process.env.BRANCH === "refs/heads/main"; 33 | } 34 | 35 | /** 36 | * Tests whether or not the current Configuration allows to release Beta version. 37 | * @returns True if the current Configuration allows to release Beta version, else False otherwise. 38 | */ 39 | function enableBetaRelease() { 40 | return ["True", "true", "1"].includes(process.env.ENABLE_BETA); 41 | } 42 | 43 | /** 44 | * Validates package version before release based on branch. 45 | * @param isMaster - True if current branch is `master, else False. 46 | * @returns True if version is valid against current branch, False otherwise. 47 | */ 48 | function validateVersion(isMaster) { 49 | if (isMaster) { 50 | return StableVersionRegex.test(packageJSON.version); 51 | } 52 | 53 | return BetaVersionRegex.test(packageJSON.version); 54 | } 55 | 56 | /** 57 | * Provides with right release tag for package. 58 | * @param isMaster - True if current branch is `master, else False. 59 | * @returns Release Tag (stable/beta). 60 | */ 61 | function getReleaseTag(isMaster) { 62 | return isMaster ? Tags.Stable : Tags.Beta; 63 | } 64 | 65 | /** 66 | * Returns Absolute Path of the given file. 67 | * @param filePath - Absolute/Relative Path of the File. 68 | * @returns Absolute Path of File. 69 | */ 70 | function resolvePath(filePath) { 71 | const currentPath = process.cwd(); 72 | 73 | if (path.isAbsolute(filePath)) { 74 | return filePath; 75 | } 76 | 77 | return path.join(currentPath, filePath); 78 | } 79 | 80 | /** 81 | * Publish npm package with proper tag. 82 | * @param tag - Tag to release the package (stable/beta). 83 | * @returns A Promise that resolves once Exec is complete. 84 | */ 85 | async function publishToNPM(tag) { 86 | const command = `NODE_AUTH_TOKEN=${process.env.NODE_AUTH_TOKEN} npm publish --tag ${tag} --access public`; 87 | 88 | try { 89 | const { stdout } = await exec(command); 90 | 91 | console.log("Published package to NPM", stdout); 92 | } catch (err) { 93 | console.log("Failed to publish to NPM registry.\n", err.stderr); 94 | process.exit(ExitCodes.Success); 95 | } 96 | } 97 | 98 | /** 99 | * Bundles artifacts into compressed archive. 100 | * @returns - A Promise that resolves name of the Archive. 101 | */ 102 | async function bundleArchive() { 103 | const command = "npm pack"; 104 | 105 | try { 106 | const { stdout } = await exec(command); 107 | const tarball = stdout.trimRight().split("\n").pop(); 108 | 109 | console.log("Archive packed:", tarball); 110 | return tarball; 111 | } catch (err) { 112 | console.log("Failed to pack archive.\n", err.stderr); 113 | process.exit(ExitCodes.Success); 114 | } 115 | } 116 | 117 | /** 118 | * Create a GitHub Release Tag. 119 | * @returns A Promise that resolves Release ID once Create is complete. 120 | */ 121 | async function createRelease() { 122 | const url = `https://api.github.com/repos/interviewstreet/firepad-x/releases`; 123 | 124 | const options = { 125 | method: "POST", 126 | headers: { 127 | ...Headers, 128 | "Content-Type": "application/json", 129 | }, 130 | body: JSON.stringify({ 131 | tag_name: packageJSON.version, 132 | }), 133 | }; 134 | 135 | console.log("Creating GitHub release for:", packageJSON.version); 136 | 137 | const response = await fetch(url, options); 138 | const data = await response.json(); 139 | 140 | console.log("Created GitHub release:", data.html_url); 141 | return data.id; 142 | } 143 | 144 | async function uploadAssets(releaseId, fileName) { 145 | const url = `https://uploads.github.com/repos/interviewstreet/firepad-x/releases/${releaseId}/assets?name=${fileName}`; 146 | 147 | const options = { 148 | method: "POST", 149 | headers: { 150 | ...Headers, 151 | "Content-Type": "application/octet-stream", 152 | }, 153 | body: fs.readFileSync(resolvePath(fileName)), 154 | }; 155 | 156 | console.log("Upload Assets: starting..."); 157 | 158 | await fetch(url, options); 159 | console.log("Upload Assets: completed."); 160 | } 161 | 162 | /** 163 | * Logs error message in case of incorrect version number. 164 | * @param isMaster - True if current branch is `master, else False. 165 | */ 166 | function logInvalidVersionNameError(isMaster) { 167 | let err = "Incorrect version number found, version number should be in the semver pattern: x.y.z"; 168 | 169 | if (!isMaster) { 170 | err += "-alpha"; 171 | } 172 | 173 | console.log(err); 174 | } 175 | 176 | /** 177 | * Main function that handles validation and attach proper tag to package.json 178 | */ 179 | async function main() { 180 | const isMaster = isMasterBranch(); 181 | 182 | if (!isMaster && !enableBetaRelease()) { 183 | console.log("Beta release is disabled for this repository."); 184 | return; 185 | } 186 | 187 | const isValidVersion = validateVersion(isMaster); 188 | 189 | if (!isValidVersion) { 190 | logInvalidVersionNameError(isMaster); 191 | return; 192 | } 193 | 194 | const releaseTag = getReleaseTag(isMaster); 195 | await publishToNPM(releaseTag); 196 | 197 | if (isMaster) { 198 | const tarBall = await bundleArchive(); 199 | const releaseId = await createRelease(); 200 | await uploadAssets(releaseId, tarBall); 201 | } 202 | } 203 | 204 | /** 205 | * Execution Hook 206 | */ 207 | !(async function () { 208 | try { 209 | await main(); 210 | } catch (err) { 211 | console.log(err); 212 | } 213 | 214 | process.exit(ExitCodes.Success); 215 | })(); 216 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventEmitter, 3 | EventListenerType, 4 | IEvent, 5 | IEventEmitter, 6 | } from "./emitter"; 7 | import { ITextOperation } from "./text-operation"; 8 | import * as Utils from "./utils"; 9 | 10 | export enum ClientEvent { 11 | ApplyOperation = "apply", 12 | SendOperation = "send", 13 | } 14 | 15 | interface IClientStateMachine { 16 | /** 17 | * Tests whether the Client State is Synchronized with Server or not. 18 | */ 19 | isSynchronized(): boolean; 20 | /** 21 | * Tests whether the Client State is Waiting for Acknowledgement with Server or not. 22 | */ 23 | isAwaitingConfirm(): boolean; 24 | /** 25 | * Tests whether the Client State is Waiting for Acknowledgement with Server along with pending Operation or not. 26 | */ 27 | isAwaitingWithBuffer(): boolean; 28 | } 29 | 30 | export interface IClient extends IClientStateMachine, Utils.IDisposable { 31 | /** 32 | * Add listener to Client. 33 | * @param event - Event name. 34 | * @param listener - Event handler callback. 35 | */ 36 | on(event: ClientEvent, listener: EventListenerType): void; 37 | /** 38 | * Remove listener to Client. 39 | * @param event - Event name. 40 | * @param listener - Event handler callback. 41 | */ 42 | off(event: ClientEvent, listener: EventListenerType): void; 43 | /** 44 | * Send operation to remote users. 45 | * @param operation - Text Operation from Editor Adapter 46 | */ 47 | applyClient(operation: ITextOperation): void; 48 | /** 49 | * Recieve operation from remote user. 50 | * @param operation - Text Operation recieved from remote user. 51 | */ 52 | applyServer(operation: ITextOperation): void; 53 | /** 54 | * Handle acknowledgement 55 | */ 56 | serverAck(): void; 57 | /** 58 | * Handle retry 59 | */ 60 | serverRetry(): void; 61 | /** 62 | * Send operation to Database adapter. 63 | * @param operation - Text Operation at client end. 64 | */ 65 | sendOperation(operation: ITextOperation): void; 66 | /** 67 | * Apply operation to Editor adapter 68 | * @param operation - Text Operation at Server end. 69 | */ 70 | applyOperation(operation: ITextOperation): void; 71 | } 72 | 73 | interface IClientSynchronizationState extends IClientStateMachine { 74 | /** 75 | * Send operation to remote users. 76 | * @param operation - Text Operation from Editor Adapter 77 | */ 78 | applyClient( 79 | client: IClient, 80 | operation: ITextOperation 81 | ): IClientSynchronizationState; 82 | /** 83 | * Recieve operation from remote user. 84 | * @param operation - Text Operation recieved from remote user. 85 | */ 86 | applyServer( 87 | client: IClient, 88 | operation: ITextOperation 89 | ): IClientSynchronizationState; 90 | /** 91 | * Handle acknowledgement 92 | */ 93 | serverAck(client: IClient): IClientSynchronizationState; 94 | /** 95 | * Handle retry 96 | */ 97 | serverRetry(client: IClient): IClientSynchronizationState; 98 | } 99 | 100 | /** 101 | * In the `Synchronized` state, there is no pending operation that the client 102 | * has sent to the server. 103 | */ 104 | class Synchronized implements IClientSynchronizationState { 105 | constructor() {} 106 | 107 | isSynchronized(): boolean { 108 | return true; 109 | } 110 | 111 | isAwaitingConfirm(): boolean { 112 | return false; 113 | } 114 | 115 | isAwaitingWithBuffer(): boolean { 116 | return false; 117 | } 118 | 119 | applyClient( 120 | client: IClient, 121 | operation: ITextOperation 122 | ): IClientSynchronizationState { 123 | // When the user makes an edit, send the operation to the server and 124 | // switch to the 'AwaitingConfirm' state 125 | client.sendOperation(operation); 126 | return new AwaitingConfirm(operation); 127 | } 128 | 129 | applyServer( 130 | client: IClient, 131 | operation: ITextOperation 132 | ): IClientSynchronizationState { 133 | // When we receive a new operation from the server, the operation can be 134 | // simply applied to the current document 135 | client.applyOperation(operation); 136 | return this; 137 | } 138 | 139 | serverAck(_client: IClient): IClientSynchronizationState { 140 | Utils.shouldNotGetCalled("There is no pending operation."); 141 | return this; 142 | } 143 | 144 | serverRetry(_client: IClient): IClientSynchronizationState { 145 | Utils.shouldNotGetCalled("There is no pending operation."); 146 | return this; 147 | } 148 | } 149 | 150 | // Singleton 151 | const _synchronized = new Synchronized(); 152 | 153 | /** 154 | * In the `AwaitingConfirm` state, there's one operation the client has sent 155 | * to the server and is still waiting for an acknowledgement. 156 | */ 157 | class AwaitingConfirm implements IClientSynchronizationState { 158 | protected readonly _outstanding: ITextOperation; 159 | 160 | constructor(outstanding: ITextOperation) { 161 | // Save the pending operation 162 | this._outstanding = outstanding; 163 | } 164 | 165 | isSynchronized(): boolean { 166 | return false; 167 | } 168 | 169 | isAwaitingConfirm(): boolean { 170 | return true; 171 | } 172 | 173 | isAwaitingWithBuffer(): boolean { 174 | return false; 175 | } 176 | 177 | applyClient( 178 | _client: IClient, 179 | operation: ITextOperation 180 | ): IClientSynchronizationState { 181 | // When the user makes an edit, don't send the operation immediately, 182 | // instead switch to 'AwaitingWithBuffer' state 183 | return new AwaitingWithBuffer(this._outstanding, operation); 184 | } 185 | 186 | applyServer( 187 | client: IClient, 188 | operation: ITextOperation 189 | ): IClientSynchronizationState { 190 | // This is another client's operation. Visualization: 191 | // 192 | // /\ 193 | // this.outstanding / \ operation 194 | // / \ 195 | // \ / 196 | // pair[1] \ / pair[0] (new outstanding) 197 | // (can be applied \/ 198 | // to the client's 199 | // current document) 200 | 201 | const pair = this._outstanding.transform(operation); 202 | client.applyOperation(pair[1]); 203 | return new AwaitingConfirm(pair[0]); 204 | } 205 | 206 | serverAck(_client: IClient): IClientSynchronizationState { 207 | // The client's operation has been acknowledged 208 | // => switch to synchronized state 209 | return _synchronized; 210 | } 211 | 212 | serverRetry(client: IClient): IClientSynchronizationState { 213 | client.sendOperation(this._outstanding); 214 | return this; 215 | } 216 | } 217 | 218 | /** 219 | * In the `AwaitingWithBuffer` state, the client is waiting for an operation 220 | * to be acknowledged by the server while buffering the edits the user makes 221 | */ 222 | class AwaitingWithBuffer implements IClientSynchronizationState { 223 | protected readonly _outstanding: ITextOperation; 224 | protected readonly _buffer: ITextOperation; 225 | 226 | constructor(outstanding: ITextOperation, buffer: ITextOperation) { 227 | // Save the pending operation and the user's edits since then 228 | this._outstanding = outstanding; 229 | this._buffer = buffer; 230 | } 231 | 232 | isSynchronized(): boolean { 233 | return false; 234 | } 235 | 236 | isAwaitingConfirm(): boolean { 237 | return false; 238 | } 239 | 240 | isAwaitingWithBuffer(): boolean { 241 | return true; 242 | } 243 | 244 | applyClient( 245 | client: IClient, 246 | operation: ITextOperation 247 | ): IClientSynchronizationState { 248 | // Compose the user's changes onto the buffer 249 | const newBuffer = this._buffer.compose(operation); 250 | return new AwaitingWithBuffer(this._outstanding, newBuffer); 251 | } 252 | 253 | applyServer( 254 | client: IClient, 255 | operation: ITextOperation 256 | ): IClientSynchronizationState { 257 | // Operation comes from another client 258 | // 259 | // /\ 260 | // this.outstanding / \ operation 261 | // / \ 262 | // /\ / 263 | // this.buffer / \* / pair1[0] (new outstanding) 264 | // / \/ 265 | // \ / 266 | // pair2[1] \ / pair2[0] (new buffer) 267 | // the transformed \/ 268 | // operation -- can 269 | // be applied to the 270 | // client's current 271 | // document 272 | // 273 | // * pair1[1] 274 | 275 | const pair1 = this._outstanding.transform(operation); 276 | const pair2 = this._buffer.transform(pair1[1]); 277 | client.applyOperation(pair2[1]); 278 | return new AwaitingWithBuffer(pair1[0], pair2[0]); 279 | } 280 | 281 | serverAck(client: IClient): IClientSynchronizationState { 282 | // The pending operation has been acknowledged 283 | // => send buffer 284 | client.sendOperation(this._buffer); 285 | return new AwaitingConfirm(this._buffer); 286 | } 287 | 288 | serverRetry(client: IClient): IClientSynchronizationState { 289 | // Merge with our buffer and resend. 290 | const outstanding = this._outstanding.compose(this._buffer); 291 | client.sendOperation(outstanding); 292 | return new AwaitingConfirm(outstanding); 293 | } 294 | } 295 | 296 | export class Client implements IClient { 297 | protected readonly _emitter: IEventEmitter; 298 | 299 | protected _state: IClientSynchronizationState; 300 | 301 | constructor() { 302 | this._state = _synchronized; 303 | this._emitter = new EventEmitter([ 304 | ClientEvent.ApplyOperation, 305 | ClientEvent.SendOperation, 306 | ]); 307 | } 308 | 309 | dispose(): void { 310 | this._emitter.dispose(); 311 | } 312 | 313 | isSynchronized(): boolean { 314 | return this._state.isSynchronized(); 315 | } 316 | 317 | isAwaitingConfirm(): boolean { 318 | return this._state.isAwaitingConfirm(); 319 | } 320 | 321 | isAwaitingWithBuffer(): boolean { 322 | return this._state.isAwaitingWithBuffer(); 323 | } 324 | 325 | on(event: ClientEvent, listener: EventListenerType): void { 326 | return this._emitter.on(event, listener as EventListenerType); 327 | } 328 | 329 | off(event: ClientEvent, listener: EventListenerType): void { 330 | return this._emitter.off(event, listener as EventListenerType); 331 | } 332 | 333 | protected _trigger(event: ClientEvent, eventArgs: ITextOperation): void { 334 | return this._emitter.trigger(event, eventArgs); 335 | } 336 | 337 | protected _setState(state: IClientSynchronizationState): void { 338 | this._state = state; 339 | } 340 | 341 | applyClient(operation: ITextOperation): void { 342 | this._setState(this._state.applyClient(this, operation)); 343 | } 344 | 345 | applyServer(operation: ITextOperation): void { 346 | this._setState(this._state.applyServer(this, operation)); 347 | } 348 | 349 | serverAck(): void { 350 | this._setState(this._state.serverAck(this)); 351 | } 352 | 353 | serverRetry(): void { 354 | this._setState(this._state.serverRetry(this)); 355 | } 356 | 357 | sendOperation(operation: ITextOperation): void { 358 | this._trigger(ClientEvent.SendOperation, operation); 359 | } 360 | 361 | applyOperation(operation: ITextOperation): void { 362 | this._trigger(ClientEvent.ApplyOperation, operation); 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/cursor-widget-controller.ts: -------------------------------------------------------------------------------- 1 | import * as monaco from "monaco-editor"; 2 | 3 | import { CursorWidget, ICursorWidget } from "./cursor-widget"; 4 | import { ClientIDType } from "./editor-adapter"; 5 | import { IDisposable } from "./utils"; 6 | 7 | export interface ICursorWidgetController extends IDisposable { 8 | /** 9 | * Add Cursor Widget for the first time for new User. 10 | * @param clientId - Unique Identifier for remote User. 11 | * @param range - Position of the Widget. 12 | * @param userColor - Border Color of the Widget. 13 | * @param userName - User's name to show up in Widget. (optional, default is set to User ID). 14 | */ 15 | addCursor( 16 | clientId: ClientIDType, 17 | range: monaco.Range, 18 | userColor: string, 19 | userName?: string 20 | ): void; 21 | /** 22 | * Update Cursor Widget for existing User, or add a new Cursor Widget if new User. 23 | * @param clientId - Unique Identifier for remote User. 24 | * @param range - Position of the Widget. 25 | * @param userColor - Border Color of the Widget. 26 | * @param userName - User's name to show up in Widget. (optional, default is set to User ID). 27 | */ 28 | updateCursor( 29 | clientId: ClientIDType, 30 | range: monaco.Range, 31 | userColor: string, 32 | userName?: string 33 | ): void; 34 | /** 35 | * Dispose Cursor Widget for existing User. 36 | * @param clientId - Unique Identifier for remote User. 37 | */ 38 | removeCursor(clientId: ClientIDType): void; 39 | } 40 | 41 | export class CursorWidgetController implements ICursorWidgetController { 42 | protected readonly _cursors: Map; 43 | protected readonly _editor: monaco.editor.IStandaloneCodeEditor; 44 | protected readonly _tooltipDuration: number; 45 | 46 | constructor(editor: monaco.editor.IStandaloneCodeEditor) { 47 | this._editor = editor; 48 | this._tooltipDuration = 1000; 49 | this._cursors = new Map(); 50 | } 51 | 52 | addCursor( 53 | clientId: ClientIDType, 54 | range: monaco.Range, 55 | userColor: string, 56 | userName?: string 57 | ): void { 58 | const cursorWidget = new CursorWidget({ 59 | codeEditor: this._editor, 60 | widgetId: clientId, 61 | color: userColor, 62 | range, 63 | label: userName || clientId.toString(), 64 | tooltipDuration: this._tooltipDuration, 65 | onDisposed: () => { 66 | this.removeCursor(clientId); 67 | }, 68 | }); 69 | 70 | this._cursors.set(clientId, cursorWidget); 71 | } 72 | 73 | removeCursor(clientId: ClientIDType): void { 74 | const cursorWidget = this._cursors.get(clientId); 75 | 76 | if (!cursorWidget) { 77 | /** Already disposed, nothing to do here. */ 78 | return; 79 | } 80 | 81 | cursorWidget.dispose(); 82 | this._cursors.delete(clientId); 83 | } 84 | 85 | updateCursor( 86 | clientId: ClientIDType, 87 | range: monaco.Range, 88 | userColor: string, 89 | userName?: string 90 | ): void { 91 | const cursorWidget = this._cursors.get(clientId); 92 | 93 | if (cursorWidget) { 94 | /** Widget already present, simple update should suffice. */ 95 | cursorWidget.updatePosition(range); 96 | cursorWidget.updateContent(userName); 97 | return; 98 | } 99 | 100 | this.addCursor(clientId, range, userColor, userName); 101 | } 102 | 103 | dispose(): void { 104 | this._cursors.forEach((cursorWidget: ICursorWidget) => 105 | cursorWidget.dispose() 106 | ); 107 | this._cursors.clear(); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/cursor-widget.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Convergence Labs, Inc. 3 | * 4 | * This file is part of the Monaco 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 * as monaco from "monaco-editor"; 11 | 12 | import { ClientIDType } from "./editor-adapter"; 13 | import * as Utils from "./utils"; 14 | 15 | type OnDisposed = Utils.VoidFunctionType; 16 | 17 | export interface ICursorWidgetConstructorOptions { 18 | codeEditor: monaco.editor.ICodeEditor; 19 | widgetId: ClientIDType; 20 | color: string; 21 | label: string; 22 | range: monaco.Range; 23 | tooltipDuration?: number; 24 | opacity?: string; 25 | onDisposed: OnDisposed; 26 | } 27 | 28 | export interface ICursorWidget 29 | extends monaco.editor.IContentWidget, 30 | Utils.IDisposable { 31 | /** 32 | * Update Widget position according to the Cursor position. 33 | * @param range - Current Position of the Cursor. 34 | */ 35 | updatePosition(range: monaco.Range): void; 36 | /** 37 | * Update Widget content when Username changes. 38 | * @param userName - New Username of the current User. 39 | */ 40 | updateContent(userName?: string): void; 41 | 42 | /** 43 | * Returns whether the widget was disposed or not. 44 | */ 45 | isDisposed(): boolean; 46 | } 47 | 48 | /** 49 | * This class implements a Monaco Content Widget to render a remote user's 50 | * name in a tooltip. 51 | */ 52 | export class CursorWidget implements ICursorWidget { 53 | protected readonly _id: string; 54 | protected readonly _editor: monaco.editor.ICodeEditor; 55 | protected readonly _domNode: HTMLElement; 56 | protected readonly _tooltipDuration: number; 57 | protected readonly _scrollListener: monaco.IDisposable | null; 58 | protected readonly _onDisposed: OnDisposed; 59 | 60 | protected _tooltipNode: HTMLElement; 61 | protected _color: string; 62 | protected _content: string; 63 | protected _opacity: string; 64 | protected _position: monaco.editor.IContentWidgetPosition | null; 65 | protected _hideTimer: any; 66 | protected _disposed: boolean; 67 | 68 | static readonly WIDGET_NODE_CLASSNAME = "firepad-remote-cursor"; 69 | static readonly TOOLTIP_NODE_CLASSNAME = "firepad-remote-cursor-message"; 70 | 71 | constructor({ 72 | codeEditor, 73 | widgetId, 74 | color, 75 | label, 76 | range, 77 | tooltipDuration = 1000, 78 | opacity = "1.0", 79 | onDisposed, 80 | }: ICursorWidgetConstructorOptions) { 81 | this._editor = codeEditor; 82 | this._tooltipDuration = tooltipDuration; 83 | this._id = `monaco-remote-cursor-${widgetId}`; 84 | this._onDisposed = onDisposed; 85 | this._color = color; 86 | this._content = label; 87 | this._opacity = opacity; 88 | 89 | this._domNode = this._createWidgetNode(); 90 | 91 | // we only need to listen to scroll positions to update the 92 | // tooltip location on scrolling. 93 | this._scrollListener = this._editor.onDidScrollChange(() => { 94 | this._updateTooltipPosition(); 95 | }); 96 | 97 | this.updatePosition(range); 98 | 99 | this._hideTimer = null; 100 | this._editor.addContentWidget(this); 101 | 102 | this._disposed = false; 103 | } 104 | 105 | getId(): string { 106 | return this._id; 107 | } 108 | 109 | getDomNode(): HTMLElement { 110 | return this._domNode; 111 | } 112 | 113 | getPosition(): monaco.editor.IContentWidgetPosition | null { 114 | return this._position; 115 | } 116 | 117 | updatePosition(range: monaco.Range): void { 118 | this._updatePosition(range); 119 | setTimeout(() => this._showTooltip(), 0); 120 | } 121 | 122 | updateContent(userName?: string): void { 123 | if (typeof userName !== "string" || userName === this._content) { 124 | return; 125 | } 126 | this._tooltipNode.textContent = userName; 127 | } 128 | 129 | dispose(): void { 130 | if (this._disposed) { 131 | return; 132 | } 133 | 134 | this._editor.removeContentWidget(this); 135 | if (this._scrollListener !== null) { 136 | this._scrollListener.dispose(); 137 | } 138 | 139 | this._disposed = true; 140 | this._onDisposed(); 141 | } 142 | 143 | isDisposed(): boolean { 144 | return this._disposed; 145 | } 146 | 147 | protected _updatePosition(range: monaco.Range): void { 148 | this._position = { 149 | position: range.getEndPosition(), 150 | preference: [ 151 | monaco.editor.ContentWidgetPositionPreference.ABOVE, 152 | monaco.editor.ContentWidgetPositionPreference.BELOW, 153 | ], 154 | }; 155 | 156 | this._editor.layoutContentWidget(this); 157 | } 158 | 159 | protected _showTooltip(): void { 160 | this._updateTooltipPosition(); 161 | 162 | if (this._hideTimer !== null) { 163 | clearTimeout(this._hideTimer); 164 | } else { 165 | this._setTooltipVisible(true); 166 | } 167 | 168 | this._hideTimer = setTimeout(() => { 169 | this._setTooltipVisible(false); 170 | this._hideTimer = null; 171 | }, this._tooltipDuration); 172 | } 173 | 174 | protected _updateTooltipPosition(): void { 175 | const distanceFromTop = 176 | this._domNode.offsetTop - this._editor.getScrollTop(); 177 | if (distanceFromTop - this._tooltipNode.offsetHeight < 5) { 178 | this._tooltipNode.style.top = `${this._tooltipNode.offsetHeight + 2}px`; 179 | } else { 180 | this._tooltipNode.style.top = `-${this._tooltipNode.offsetHeight}px`; 181 | } 182 | 183 | this._tooltipNode.style.left = "0"; 184 | } 185 | 186 | protected _setTooltipVisible(visible: boolean): void { 187 | if (visible) { 188 | this._tooltipNode.style.display = "block"; 189 | } else { 190 | this._tooltipNode.style.display = "none"; 191 | } 192 | } 193 | 194 | protected _colorWithCSSVars(property: string): string { 195 | const varName = `--color-${property}-${CursorWidget.WIDGET_NODE_CLASSNAME}`; 196 | return `var(${varName}, ${this._color})`; 197 | } 198 | 199 | protected _getTextColor(): string { 200 | const rgb = Utils.hexToRgb(this._color); 201 | const brightness = Math.round( 202 | (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000 203 | ); 204 | 205 | return brightness >= 125 ? "#000000" : "#ffffff"; 206 | } 207 | 208 | protected _createTooltipNode(): HTMLElement { 209 | const tooltipNode = document.createElement("div"); 210 | 211 | tooltipNode.style.borderColor = this._colorWithCSSVars("border"); 212 | tooltipNode.style.backgroundColor = this._colorWithCSSVars("bg"); 213 | tooltipNode.style.color = this._getTextColor(); 214 | tooltipNode.style.opacity = this._opacity; 215 | tooltipNode.style.borderRadius = "2px"; 216 | tooltipNode.style.fontSize = "12px"; 217 | tooltipNode.style.padding = "2px 8px"; 218 | tooltipNode.style.whiteSpace = "nowrap"; 219 | 220 | tooltipNode.textContent = this._content; 221 | 222 | const className = `${ 223 | CursorWidget.TOOLTIP_NODE_CLASSNAME 224 | }-${this._color.replace("#", "")}`; 225 | tooltipNode.classList.add(className, CursorWidget.TOOLTIP_NODE_CLASSNAME); 226 | 227 | return tooltipNode; 228 | } 229 | 230 | protected _createWidgetNode(): HTMLElement { 231 | Utils.validateTruth(document != null, "This package must run on browser!"); 232 | 233 | const widgetNode = document.createElement("div"); 234 | widgetNode.style.height = "20px"; 235 | widgetNode.style.paddingBottom = "0px"; 236 | widgetNode.style.transition = "all 0.1s linear"; 237 | 238 | this._tooltipNode = this._createTooltipNode(); 239 | widgetNode.appendChild(this._tooltipNode); 240 | 241 | widgetNode.classList.add( 242 | "monaco-editor-overlaymessage", 243 | CursorWidget.WIDGET_NODE_CLASSNAME 244 | ); 245 | 246 | return widgetNode; 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/cursor.ts: -------------------------------------------------------------------------------- 1 | import { ITextOp } from "./text-op"; 2 | import { ITextOperation } from "./text-operation"; 3 | 4 | /** 5 | * JSON Representation of a Cursor Object 6 | * 7 | * If `position` and `selectionEnd` are equal that means 8 | * a single cursor; otherwise, it's a selection between 9 | * two points. 10 | */ 11 | export type CursorType = { 12 | /** Starting Position of the Cursor/Selection */ 13 | position: number; 14 | /** Final Position of the Selection */ 15 | selectionEnd: number; 16 | }; 17 | 18 | export interface ICursor { 19 | /** 20 | * Checks if two Cursor are at same position. 21 | * @param other - Another Cursor Object (could be null). 22 | */ 23 | equals(other: ICursor | null): boolean; 24 | /** 25 | * Return the more current cursor information. 26 | * @param other - Another Cursor Object. 27 | */ 28 | compose(other: ICursor): ICursor; 29 | /** 30 | * Update the cursor with respect to an operation. 31 | * @param operation - Text Operation. 32 | */ 33 | transform(operation: ITextOperation): ICursor; 34 | /** 35 | * Returns JSON representation of the Cursor. 36 | */ 37 | toJSON(): CursorType; 38 | } 39 | 40 | /** 41 | * A cursor has a `position` and a `selectionEnd`. Both are zero-based indexes 42 | * into the document. When nothing is selected, `selectionEnd` is equal to 43 | * `position`. When there is a selection, `position` is always the side of the 44 | * selection that would move if you pressed an arrow key. 45 | */ 46 | export class Cursor implements ICursor { 47 | protected readonly _position: number; 48 | protected readonly _selectionEnd: number; 49 | 50 | /** 51 | * Creates a Cursor object 52 | * @param position - Starting position of the Cursor 53 | * @param selectionEnd - Ending position of the Cursor 54 | */ 55 | constructor(position: number, selectionEnd: number) { 56 | this._position = position; 57 | this._selectionEnd = selectionEnd; 58 | } 59 | 60 | /** 61 | * Converts a JSON representation of Cursor into Cursor Object 62 | */ 63 | static fromJSON({ position, selectionEnd }: CursorType): Cursor { 64 | return new Cursor(position, selectionEnd); 65 | } 66 | 67 | equals(other: Cursor | null): boolean { 68 | if (other == null) { 69 | return false; 70 | } 71 | 72 | return ( 73 | this._position === other._position && 74 | this._selectionEnd === other._selectionEnd 75 | ); 76 | } 77 | 78 | compose(other: Cursor): Cursor { 79 | return other; 80 | } 81 | 82 | protected _transformIndex(ops: ITextOp[], index: number) { 83 | let newIndex = index; 84 | 85 | for (const op of ops) { 86 | if (op.isRetain()) { 87 | index -= op.chars!; 88 | } else if (op.isInsert()) { 89 | newIndex += op.text!.length; 90 | } else { 91 | newIndex -= Math.min(index, op.chars!); 92 | index -= op.chars!; 93 | } 94 | 95 | if (index < 0) { 96 | break; 97 | } 98 | } 99 | 100 | return newIndex; 101 | } 102 | 103 | transform(operation: ITextOperation): Cursor { 104 | const ops: ITextOp[] = operation.getOps(); 105 | const newPosition: number = this._transformIndex(ops, this._position); 106 | 107 | if (this._position === this._selectionEnd) { 108 | return new Cursor(newPosition, newPosition); 109 | } 110 | 111 | return new Cursor( 112 | newPosition, 113 | this._transformIndex(ops, this._selectionEnd) 114 | ); 115 | } 116 | 117 | toJSON(): CursorType { 118 | return { 119 | position: this._position, 120 | selectionEnd: this._selectionEnd, 121 | }; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/database-adapter.ts: -------------------------------------------------------------------------------- 1 | import { ICursor } from "./cursor"; 2 | import { EventListenerType, IEvent } from "./emitter"; 3 | import { ITextOperation } from "./text-operation"; 4 | import { IDisposable } from "./utils"; 5 | 6 | export type UserIDType = string | number; 7 | 8 | /** Additional State Information about Database Adapter */ 9 | export type DatabaseAdapterStateType = { 10 | /** String representation of the Text Operation */ 11 | operation: string; 12 | /** String representation of the current Document */ 13 | document: string; 14 | }; 15 | 16 | /** Event parameters for `error` event on Database Adapter */ 17 | export type DatabaseOnErrorFunctionType = ( 18 | /** Original Error event */ 19 | error: Error, 20 | /** String representation of the Text Operation */ 21 | operation: string, 22 | /** Additional State Information about Editor Adapter */ 23 | state: DatabaseAdapterStateType 24 | ) => void; 25 | 26 | export enum DatabaseAdapterEvent { 27 | Ready = "ready", 28 | CursorChange = "cursor", 29 | Operation = "operation", 30 | Acknowledge = "ack", 31 | Retry = "retry", 32 | Error = "error", 33 | InitialRevision = "initialRevision", 34 | } 35 | 36 | export interface IDatabaseAdapterEvent extends IEvent {} 37 | 38 | export type DatabaseAdapterCallbackType = { 39 | [DatabaseAdapterEvent.Error]: DatabaseOnErrorFunctionType; 40 | [DatabaseAdapterEvent.Retry]: EventListenerType; 41 | [DatabaseAdapterEvent.Operation]: EventListenerType; 42 | [DatabaseAdapterEvent.Acknowledge]: EventListenerType; 43 | [DatabaseAdapterEvent.CursorChange]: EventListenerType; 44 | [DatabaseAdapterEvent.InitialRevision]: EventListenerType; 45 | }; 46 | 47 | /** 48 | * Callback to handle transaction of `sendOperation` call. 49 | * Called with two arguments: 50 | * 51 | * * `err` - Error if Transaction fails, else `null`. 52 | * * `commited` - Whether or not the changes have been commited. 53 | */ 54 | export type SendOperationCallbackType = ( 55 | err: Error | null, 56 | commited: boolean 57 | ) => void; 58 | 59 | /** 60 | * Callback to handle transaction of `sendCursor` call. 61 | * Called with two arguments: 62 | * 63 | * * `err` - Error if Transaction fails, else `null`. 64 | * * `syncedCursor` - The Cursor that has been synced. (could be `null`) 65 | */ 66 | export type SendCursorCallbackType = ( 67 | err: Error | null, 68 | syncedCursor: ICursor | null 69 | ) => void; 70 | 71 | export interface IDatabaseAdapter extends IDisposable { 72 | /** 73 | * Tests if any operation has been done yet on the document. 74 | */ 75 | isHistoryEmpty(): boolean; 76 | /** 77 | * Returns current state of the document (could be `null`). 78 | */ 79 | getDocument(): ITextOperation | null; 80 | /** 81 | * Add Unique Identifier against current user to mark Cursor and Operations. 82 | * @param userId - Unique Identifier for current user. 83 | */ 84 | setUserId(userId: UserIDType): void; 85 | /** 86 | * Set Color to mark current user's Cursor. 87 | * @param userColor - Color of current user's Cursor. 88 | */ 89 | setUserColor(userColor: string): void; 90 | /** 91 | * Set User Name to mark current user's Cursor. 92 | * @param userName - Name of current user. 93 | */ 94 | setUserName(userName: string): void; 95 | /** 96 | * Tests if `clientId` matches current user's ID. 97 | * @param clientId - Unique Identifier for user. 98 | */ 99 | isCurrentUser(clientId: string): boolean; 100 | /** 101 | * Send operation, retrying on connection failure. Takes an optional callback with signature: 102 | * `function(error, committed)`. 103 | * An exception will be thrown on transaction failure, which should only happen on 104 | * catastrophic failure like a security rule violation. 105 | * @param operation - Text Operation to sent to server. 106 | * @param callback - Callback handler for the transaction. 107 | */ 108 | sendOperation( 109 | operation: ITextOperation, 110 | callback?: SendOperationCallbackType 111 | ): void; 112 | /** 113 | * Send current user's cursor information to server. 114 | * @param cursor - Cursor of Current User. 115 | * @param callback - Callback handler for the transaction. 116 | */ 117 | sendCursor(cursor: ICursor | null, callback?: SendCursorCallbackType): void; 118 | /** 119 | * Add listener to Database Adapter. 120 | * @param event - Event name. 121 | * @param listener - Event handler callback. 122 | */ 123 | on( 124 | event: DatabaseAdapterEvent, 125 | listener: EventListenerType 126 | ): void; 127 | /** 128 | * Remove listener to Database Adapter. 129 | * @param event - Event name. 130 | * @param listener - Event handler callback. 131 | */ 132 | off( 133 | event: DatabaseAdapterEvent, 134 | listener: EventListenerType 135 | ): void; 136 | /** 137 | * Add multiple listener to Database Adapter. 138 | * @param callback - Map of Database Events and Callbacks. 139 | */ 140 | registerCallbacks(callback: DatabaseAdapterCallbackType): void; 141 | } 142 | -------------------------------------------------------------------------------- /src/editor-adapter.ts: -------------------------------------------------------------------------------- 1 | import { ICursor } from "./cursor"; 2 | import { EventListenerType, IEvent } from "./emitter"; 3 | import { ITextOperation } from "./text-operation"; 4 | import { IDisposable, VoidFunctionType } from "./utils"; 5 | 6 | export type UndoRedoCallbackType = VoidFunctionType; 7 | export type ClientIDType = string | number; 8 | 9 | /** Event handler callback Parameters for `change` event */ 10 | type EditorOnChangeFunctionType = ( 11 | operation: ITextOperation, 12 | inverse: ITextOperation 13 | ) => void; 14 | 15 | /** Additional State Information about Editor Adapter */ 16 | export type EditorAdapterStateType = { 17 | retain: number; 18 | skippedChars: number; 19 | contentLength: number; 20 | }; 21 | 22 | /** Event parameters for `error` event on Editor Adapter */ 23 | export type EditorOnErrorFunctionType = ( 24 | /** Original Error event */ 25 | error: Error, 26 | /** String representation of the Text Operation */ 27 | operation: string, 28 | /** Additional State Information about Editor Adapter */ 29 | state: EditorAdapterStateType 30 | ) => void; 31 | 32 | export enum EditorAdapterEvent { 33 | Error = "error", 34 | Blur = "blur", 35 | Focus = "focus", 36 | Undo = "undo", 37 | Redo = "redo", 38 | Change = "change", 39 | CursorActivity = "cursorActivity", 40 | } 41 | 42 | export type EditorEventCallbackType = { 43 | [EditorAdapterEvent.Blur]: VoidFunctionType; 44 | [EditorAdapterEvent.Focus]: VoidFunctionType; 45 | [EditorAdapterEvent.Error]: EditorOnErrorFunctionType; 46 | [EditorAdapterEvent.Change]: EditorOnChangeFunctionType; 47 | [EditorAdapterEvent.CursorActivity]: VoidFunctionType; 48 | }; 49 | 50 | export interface IEditorAdapterEvent extends IEvent {} 51 | 52 | export interface IEditorAdapter extends IDisposable { 53 | /** 54 | * Add listener to Editor Adapter. 55 | * @param event - Event name. 56 | * @param listener - Event handler callback. 57 | */ 58 | on( 59 | event: EditorAdapterEvent, 60 | listener: EventListenerType 61 | ): void; 62 | /** 63 | * Remove listener to Editor Adapter. 64 | * @param event - Event name. 65 | * @param listener - Event handler callback. 66 | */ 67 | off( 68 | event: EditorAdapterEvent, 69 | listener: EventListenerType 70 | ): void; 71 | /** 72 | * Attaches callback for Editor Event handling from top-level. 73 | * @param callbacks - Map of Editor Events and Callbacks. 74 | */ 75 | registerCallbacks(callbacks: EditorEventCallbackType): void; 76 | /** 77 | * Attaches callback for Editor Event handling from top-level. 78 | * @param callback - Undo Event Handler. 79 | */ 80 | registerUndo(callback: UndoRedoCallbackType): void; 81 | /** 82 | * Attaches callback for Editor Event handling from top-level. 83 | * @param callback - Redo Event Handler. 84 | */ 85 | registerRedo(callback: UndoRedoCallbackType): void; 86 | /** 87 | * Returns Cursor position of current User in Editor. 88 | */ 89 | getCursor(): ICursor | null; 90 | /** 91 | * Add Cursor position of current User in Editor. 92 | * @param cursor - Cursor position of Current User. 93 | */ 94 | setCursor(cursor: ICursor): void; 95 | /** 96 | * Add Cursor position of Remote Users in Editor. 97 | * @param clientID - Remote User ID. 98 | * @param cursor - Cursor Object of Remote User. 99 | * @param userColor - HexaDecimal/RGB Color Code for Cursor/Selection. 100 | * @param userName - User Name to show on Cursor (optional). 101 | */ 102 | setOtherCursor( 103 | clientID: ClientIDType, 104 | cursor: ICursor, 105 | userColor: string, 106 | userName?: string 107 | ): IDisposable; 108 | /** 109 | * Returns current content of the Editor. 110 | */ 111 | getText(): string; 112 | /** 113 | * Sets current content of the Editor. 114 | * @param text - Text Content. 115 | */ 116 | setText(text: string): void; 117 | /** 118 | * Applies operation into Editor. 119 | * @param operation - Text Operation. 120 | */ 121 | setInitiated(init: boolean): void; 122 | /** 123 | * Sets the inititated boolean which in turn allows onChange events to progress 124 | * @param _initiated - initiated boolean that represent initial firebase Revisions. 125 | */ 126 | applyOperation(operation: ITextOperation): void; 127 | /** 128 | * Returns invert operation based on current Editor content 129 | * @param operation - Text Operation. 130 | */ 131 | invertOperation(operation: ITextOperation): ITextOperation; 132 | } 133 | -------------------------------------------------------------------------------- /src/editor-client.ts: -------------------------------------------------------------------------------- 1 | import { Client, ClientEvent, IClient } from "./client"; 2 | import { Cursor, CursorType, ICursor } from "./cursor"; 3 | import { IDatabaseAdapter } from "./database-adapter"; 4 | import { IEditorAdapter } from "./editor-adapter"; 5 | import { 6 | EventEmitter, 7 | EventListenerType, 8 | IEvent, 9 | IEventEmitter, 10 | } from "./emitter"; 11 | import { OperationMeta } from "./operation-meta"; 12 | import { IRemoteClient, RemoteClient } from "./remote-client"; 13 | import { ITextOperation } from "./text-operation"; 14 | import { IUndoManager, UndoManager } from "./undo-manager"; 15 | import { IDisposable } from "./utils"; 16 | import { IWrappedOperation, WrappedOperation } from "./wrapped-operation"; 17 | 18 | export enum EditorClientEvent { 19 | Undo = "undo", 20 | Redo = "redo", 21 | Error = "error", 22 | Synced = "synced", 23 | } 24 | 25 | export interface IEditorClientEvent extends IEvent {} 26 | 27 | export interface IEditorClient extends IDisposable { 28 | /** 29 | * Add listener to Editor Client. 30 | * @param event - Event name. 31 | * @param listener - Event handler callback. 32 | */ 33 | on( 34 | event: EditorClientEvent, 35 | listener: EventListenerType 36 | ): void; 37 | /** 38 | * Remove listener to Editor Client. 39 | * @param event - Event name. 40 | * @param listener - Event handler callback. 41 | */ 42 | off( 43 | event: EditorClientEvent, 44 | listener: EventListenerType 45 | ): void; 46 | /** 47 | * Clears undo redo stack of current Editor model. 48 | */ 49 | clearUndoRedoStack(): void; 50 | } 51 | 52 | export class EditorClient implements IEditorClient { 53 | protected readonly _client: IClient; 54 | protected readonly _databaseAdapter: IDatabaseAdapter; 55 | protected readonly _editorAdapter: IEditorAdapter; 56 | protected readonly _emitter: IEventEmitter; 57 | protected readonly _remoteClients: Map; 58 | protected readonly _undoManager: IUndoManager; 59 | 60 | protected _cursor: ICursor | null; 61 | protected _focused: boolean; 62 | protected _sendCursorTimeout: NodeJS.Timeout | null; 63 | 64 | /** 65 | * Provides a channel of communication between database server and editor wrapped inside adapter 66 | * @param databaseAdapter - Database connector wrapped with Adapter interface 67 | * @param editorAdapter - Editor instance wrapped with Adapter interface 68 | */ 69 | constructor( 70 | databaseAdapter: IDatabaseAdapter, 71 | editorAdapter: IEditorAdapter 72 | ) { 73 | this._focused = false; 74 | this._cursor = null; 75 | this._sendCursorTimeout = null; 76 | 77 | this._client = new Client(); 78 | this._databaseAdapter = databaseAdapter; 79 | this._editorAdapter = editorAdapter; 80 | this._undoManager = new UndoManager(); 81 | this._remoteClients = new Map(); 82 | this._emitter = new EventEmitter([ 83 | EditorClientEvent.Error, 84 | EditorClientEvent.Undo, 85 | EditorClientEvent.Redo, 86 | EditorClientEvent.Synced, 87 | ]); 88 | 89 | this._init(); 90 | } 91 | 92 | protected _init(): void { 93 | this._editorAdapter.registerCallbacks({ 94 | change: (operation: ITextOperation, inverse: ITextOperation) => { 95 | this._onChange(operation, inverse); 96 | }, 97 | cursorActivity: () => { 98 | this._onCursorActivity(); 99 | }, 100 | blur: () => { 101 | this._onBlur(); 102 | }, 103 | focus: () => { 104 | this._onFocus(); 105 | }, 106 | error: (err, operation, state) => { 107 | this._trigger(EditorClientEvent.Error, err, operation, state); 108 | }, 109 | }); 110 | 111 | this._editorAdapter.registerUndo(() => { 112 | this._undo(); 113 | }); 114 | 115 | this._editorAdapter.registerRedo(() => { 116 | this._redo(); 117 | }); 118 | 119 | this._databaseAdapter.registerCallbacks({ 120 | ack: () => { 121 | this._client.serverAck(); 122 | this._updateCursor(); 123 | this._sendCursor(this._cursor); 124 | this._emitSynced(); 125 | }, 126 | retry: () => { 127 | this._client.serverRetry(); 128 | }, 129 | operation: (operation: ITextOperation) => { 130 | this._client.applyServer(operation); 131 | }, 132 | initialRevision: () => { 133 | this._editorAdapter.setInitiated(true); 134 | }, 135 | cursor: ( 136 | clientId: string, 137 | cursor: CursorType | null, 138 | userColor?: string, 139 | userName?: string 140 | ) => { 141 | if ( 142 | this._databaseAdapter.isCurrentUser(clientId) || 143 | !this._client.isSynchronized() 144 | ) { 145 | return; 146 | } 147 | 148 | const client = this._getClientObject(clientId); 149 | 150 | if (!cursor) { 151 | client.removeCursor(); 152 | return; 153 | } 154 | 155 | if (userColor) { 156 | client.setColor(userColor); 157 | } 158 | 159 | if (userName) { 160 | client.setUserName(userName); 161 | } 162 | 163 | client.updateCursor(Cursor.fromJSON(cursor)); 164 | }, 165 | error: (err, operation, state) => { 166 | this._trigger(EditorClientEvent.Error, err, operation, state); 167 | }, 168 | }); 169 | 170 | this._client.on(ClientEvent.ApplyOperation, (operation: ITextOperation) => { 171 | this._applyOperation(operation); 172 | }); 173 | 174 | this._client.on(ClientEvent.SendOperation, (operation: ITextOperation) => { 175 | this._sendOperation(operation); 176 | }); 177 | } 178 | 179 | dispose(): void { 180 | if (this._sendCursorTimeout) { 181 | clearTimeout(this._sendCursorTimeout); 182 | this._sendCursorTimeout = null; 183 | } 184 | 185 | this._emitter.dispose(); 186 | this._client.dispose(); 187 | this._undoManager.dispose(); 188 | this._remoteClients.clear(); 189 | } 190 | 191 | on( 192 | event: EditorClientEvent, 193 | listener: EventListenerType 194 | ): void { 195 | return this._emitter.on(event, listener); 196 | } 197 | 198 | off( 199 | event: EditorClientEvent, 200 | listener: EventListenerType 201 | ): void { 202 | return this._emitter.off(event, listener); 203 | } 204 | 205 | protected _trigger( 206 | event: EditorClientEvent, 207 | eventArgs: IEditorClientEvent, 208 | ...extraArgs: unknown[] 209 | ): void { 210 | return this._emitter.trigger(event, eventArgs, ...extraArgs); 211 | } 212 | 213 | protected _emitSynced() { 214 | this._trigger(EditorClientEvent.Synced, this._client.isSynchronized()); 215 | } 216 | 217 | protected _getClientObject(clientId: string): IRemoteClient { 218 | let client = this._remoteClients.get(clientId); 219 | 220 | if (client) { 221 | return client; 222 | } 223 | 224 | client = new RemoteClient(clientId, this._editorAdapter); 225 | this._remoteClients.set(clientId, client); 226 | 227 | return client; 228 | } 229 | 230 | protected _onChange(operation: ITextOperation, inverse: ITextOperation) { 231 | const cursorBefore = this._cursor; 232 | this._updateCursor(); 233 | 234 | const compose = 235 | this._undoManager.canUndo() && 236 | inverse.shouldBeComposedWithInverted(this._undoManager.last()!); 237 | 238 | const inverseMeta = new OperationMeta(this._cursor, cursorBefore); 239 | this._undoManager.add(new WrappedOperation(inverse, inverseMeta), compose); 240 | this._client.applyClient(operation); 241 | } 242 | 243 | clearUndoRedoStack(): void { 244 | this._undoManager.dispose(); 245 | } 246 | 247 | protected _applyUnredo(wrappedOperation: IWrappedOperation) { 248 | this._undoManager.add( 249 | this._editorAdapter.invertOperation(wrappedOperation) 250 | ); 251 | 252 | const operation = wrappedOperation.getOperation(); 253 | this._editorAdapter.applyOperation(operation); 254 | 255 | this._cursor = wrappedOperation.getCursor(); 256 | if (this._cursor) { 257 | this._editorAdapter.setCursor(this._cursor); 258 | } 259 | 260 | this._client.applyClient(operation); 261 | } 262 | 263 | protected _undo() { 264 | if (!this._undoManager.canUndo()) { 265 | return; 266 | } 267 | 268 | this._undoManager.performUndo((operation: IWrappedOperation) => { 269 | this._applyUnredo(operation); 270 | this._trigger(EditorClientEvent.Undo, operation.toString()); 271 | }); 272 | } 273 | 274 | protected _redo() { 275 | if (!this._undoManager.canRedo()) { 276 | return; 277 | } 278 | 279 | this._undoManager.performRedo((operation: IWrappedOperation) => { 280 | this._applyUnredo(operation); 281 | this._trigger(EditorClientEvent.Redo, operation.toString()); 282 | }); 283 | } 284 | 285 | protected _sendOperation(operation: ITextOperation): void { 286 | this._databaseAdapter.sendOperation(operation); 287 | } 288 | 289 | protected _applyOperation(operation: ITextOperation): void { 290 | this._editorAdapter.applyOperation(operation); 291 | this._updateCursor(); 292 | this._undoManager.transform(new WrappedOperation(operation)); 293 | this._emitSynced(); 294 | } 295 | 296 | protected _sendCursor(cursor: ICursor | null) { 297 | if (this._sendCursorTimeout) { 298 | clearTimeout(this._sendCursorTimeout); 299 | this._sendCursorTimeout = null; 300 | } 301 | 302 | if (this._client.isAwaitingWithBuffer()) { 303 | this._sendCursorTimeout = setTimeout(() => { 304 | this._sendCursor(cursor); 305 | }, 3); 306 | return; 307 | } 308 | 309 | this._databaseAdapter.sendCursor(cursor); 310 | } 311 | 312 | protected _updateCursor() { 313 | this._cursor = this._editorAdapter.getCursor(); 314 | } 315 | 316 | protected _onCursorActivity() { 317 | const oldCursor = this._cursor; 318 | this._updateCursor(); 319 | 320 | if (oldCursor == null && oldCursor == this._cursor) { 321 | /** Empty Cursor */ 322 | return; 323 | } 324 | 325 | this._sendCursor(this._cursor); 326 | } 327 | 328 | protected _onBlur() { 329 | this._cursor = null; 330 | this._sendCursor(null); 331 | this._focused = false; 332 | } 333 | 334 | protected _onFocus() { 335 | this._focused = true; 336 | this._onCursorActivity(); 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /src/emitter.ts: -------------------------------------------------------------------------------- 1 | import * as Utils from "./utils"; 2 | 3 | export type EventType = string; 4 | 5 | export type EventListenerType = (eventArgs: E, ...extraArgs: any[]) => void; 6 | 7 | export interface IEvent {} 8 | 9 | export interface IEventListener { 10 | context: ThisType | null; 11 | callback: EventListenerType; 12 | } 13 | 14 | export type EventListeners = { 15 | [event: string]: IEventListener[]; 16 | }; 17 | 18 | export interface IEventEmitter 19 | extends Utils.IDisposable { 20 | /** 21 | * Add listener to emitter 22 | * @param event - Event name 23 | * @param listener - Callback handler 24 | * @param context - Scope of Callback 25 | */ 26 | on(event: T, listener: EventListenerType, context?: ThisType): void; 27 | /** 28 | * Remove listener from emitter 29 | * @param event - Event name 30 | * @param listener - Callback handler 31 | */ 32 | off(event: T, listener: EventListenerType): void; 33 | /** 34 | * Trigger an event to listeners 35 | * @param event - Event Name 36 | * @param eventAttr - Event Attributes 37 | * @param extraAttrs - Additionnal Attributes 38 | */ 39 | trigger(event: EventType, eventAttr: E, ...extraAttrs: unknown[]): void; 40 | } 41 | 42 | export class EventEmitter 43 | implements IEventEmitter { 44 | protected readonly _allowedEvents: EventType[] | void; 45 | protected readonly _eventListeners: EventListeners; 46 | 47 | /** 48 | * Creates an Event Emitter. 49 | * @param allowedEvents - Set of Events that Emitter supports (optional). 50 | */ 51 | constructor(allowedEvents?: EventType[]) { 52 | this._allowedEvents = allowedEvents; 53 | this._eventListeners = {}; 54 | } 55 | 56 | on( 57 | event: EventType, 58 | listener: EventListenerType, 59 | context?: ThisType 60 | ): void { 61 | this._validateEvent(event); 62 | 63 | this._eventListeners[event] ||= []; 64 | this._eventListeners[event].push({ 65 | callback: listener, 66 | context: context || null, 67 | }); 68 | } 69 | 70 | off(event: EventType, listener: EventListenerType): void { 71 | this._validateEvent(event); 72 | 73 | const eventListeners = this._eventListeners[event]; 74 | 75 | if (!eventListeners || eventListeners.length === 0) { 76 | return; 77 | } 78 | 79 | this._eventListeners[event] = eventListeners.filter( 80 | (eventListener) => eventListener.callback !== listener 81 | ); 82 | } 83 | 84 | trigger(event: EventType, eventAtrr: E, ...extraAttrs: any[]): void { 85 | const eventListeners = this._eventListeners[event] || []; 86 | 87 | for (const eventListener of eventListeners) { 88 | const { context, callback } = eventListener; 89 | callback.call(context, eventAtrr, ...extraAttrs); 90 | } 91 | } 92 | 93 | dispose(): void { 94 | for (const event in this._eventListeners) { 95 | this._eventListeners[event] = []; 96 | } 97 | } 98 | 99 | protected _validateEvent(event: EventType): void { 100 | if (!this._allowedEvents || this._allowedEvents.includes(event)) { 101 | return; 102 | } 103 | 104 | Utils.shouldNotBeListenedTo(event); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/firepad-classic.ts: -------------------------------------------------------------------------------- 1 | import firebase from "firebase"; 2 | 3 | import * as monaco from "monaco-editor"; 4 | 5 | import { Cursor } from "./cursor"; 6 | import { 7 | DatabaseAdapterEvent, 8 | IDatabaseAdapter, 9 | IDatabaseAdapterEvent, 10 | UserIDType, 11 | } from "./database-adapter"; 12 | import { IEditorAdapter, IEditorAdapterEvent } from "./editor-adapter"; 13 | import { 14 | EditorClient, 15 | EditorClientEvent, 16 | IEditorClient, 17 | } from "./editor-client"; 18 | import { EventEmitter, EventListenerType, IEventEmitter } from "./emitter"; 19 | import { FirebaseAdapter } from "./firebase-adapter"; 20 | import { FirepadEvent as FirepadClassicEvent, IFirepad } from "./firepad"; 21 | import { MonacoAdapter } from "./monaco-adapter"; 22 | import * as Utils from "./utils"; 23 | 24 | interface IFirepadClassicConstructorOptions { 25 | /** Unique Identifier for current User */ 26 | userId?: UserIDType; 27 | /** Unique Hexadecimal color code for current User */ 28 | userColor?: string; 29 | /** Name/Short Name of the current User */ 30 | userName?: string; 31 | /** Default content of Firepad */ 32 | defaultText?: string; 33 | } 34 | 35 | export default class FirepadClassic implements IFirepad { 36 | protected readonly _options: IFirepadClassicConstructorOptions; 37 | protected readonly _editorClient: IEditorClient; 38 | protected readonly _editorAdapter: IEditorAdapter; 39 | protected readonly _databaseAdapter: IDatabaseAdapter; 40 | 41 | protected _ready: boolean; 42 | protected _zombie: boolean; 43 | protected _emitter: IEventEmitter | null; 44 | 45 | /** 46 | * Creates a classic Firepad 47 | * @deprecated 48 | * @param databaseRef - Firebase database Reference object. 49 | * @param editor - Editor instance (expected Monaco). 50 | * @param options - Firepad constructor options (optional). 51 | */ 52 | constructor( 53 | databaseRef: firebase.database.Reference, 54 | editor: monaco.editor.IStandaloneCodeEditor, 55 | options: IFirepadClassicConstructorOptions = {} 56 | ) { 57 | /** If not called with `new` operator */ 58 | if (!new.target) { 59 | return new FirepadClassic(databaseRef, editor, options); 60 | } 61 | 62 | this._ready = false; 63 | this._zombie = false; 64 | this._options = options; 65 | 66 | options.userId = this._getOptions("userId", () => databaseRef.push().key); 67 | options.userColor = this._getOptions("userColor", () => 68 | Utils.colorFromUserId(options.userId!.toString()) 69 | ); 70 | options.userName = this._getOptions("userName", () => options.userId); 71 | options.defaultText = this._getOptions("defaultText", () => 72 | editor.getValue() 73 | ); 74 | 75 | editor.setValue(""); // Ensure editor is empty before wraping with adapter 76 | 77 | this._databaseAdapter = new FirebaseAdapter( 78 | databaseRef, 79 | options.userId!, 80 | options.userColor!, 81 | options.userName! 82 | ); 83 | this._editorAdapter = new MonacoAdapter(editor, false); 84 | this._editorClient = new EditorClient( 85 | this._databaseAdapter, 86 | this._editorAdapter 87 | ); 88 | 89 | this._emitter = new EventEmitter([ 90 | FirepadClassicEvent.Ready, 91 | FirepadClassicEvent.Synced, 92 | FirepadClassicEvent.Undo, 93 | FirepadClassicEvent.Redo, 94 | ]); 95 | 96 | this.init(); 97 | } 98 | 99 | protected init(): void { 100 | this._databaseAdapter.on(DatabaseAdapterEvent.Ready, () => { 101 | this._ready = true; 102 | 103 | const defaultText = this._getOptions("defaultText", () => null); 104 | if (defaultText && this.isHistoryEmpty()) { 105 | this.setText(defaultText); 106 | this.clearUndoRedoStack(); 107 | } 108 | 109 | this._trigger(FirepadClassicEvent.Ready, true); 110 | }); 111 | 112 | this._editorClient.on( 113 | EditorClientEvent.Synced, 114 | (synced: boolean | IDatabaseAdapterEvent) => { 115 | this._trigger(FirepadClassicEvent.Synced, synced); 116 | } 117 | ); 118 | 119 | this._editorClient.on( 120 | EditorClientEvent.Undo, 121 | (undoOperation: string | IEditorAdapterEvent) => { 122 | this._trigger(FirepadClassicEvent.Undo, undoOperation); 123 | } 124 | ); 125 | 126 | this._editorClient.on( 127 | EditorClientEvent.Redo, 128 | (redoOperation: string | IEditorAdapterEvent) => { 129 | this._trigger(FirepadClassicEvent.Redo, redoOperation); 130 | } 131 | ); 132 | } 133 | 134 | dispose(): void { 135 | this._databaseAdapter.dispose(); 136 | this._editorAdapter.dispose(); 137 | this._editorClient.dispose(); 138 | 139 | if (this._emitter) { 140 | this._trigger(FirepadClassicEvent.Ready, false); 141 | this._emitter.dispose(); 142 | this._emitter = null; 143 | } 144 | } 145 | 146 | on(event: FirepadClassicEvent, listener: EventListenerType): void { 147 | return this._emitter?.on(event, listener); 148 | } 149 | 150 | off(event: FirepadClassicEvent, listener: EventListenerType): void { 151 | return this._emitter?.off(event, listener); 152 | } 153 | 154 | protected _trigger( 155 | event: FirepadClassicEvent, 156 | eventAttr: any, 157 | ...extraArgs: any[] 158 | ): void { 159 | return this._emitter?.trigger(event, eventAttr, ...extraArgs); 160 | } 161 | 162 | isHistoryEmpty(): boolean { 163 | this._assertReady("isHistoryEmpty"); 164 | return this._databaseAdapter.isHistoryEmpty(); 165 | } 166 | 167 | setUserId(userId: string | number): void { 168 | this._databaseAdapter.setUserId(userId); 169 | this._options.userId = userId; 170 | } 171 | 172 | setUserColor(userColor: string): void { 173 | this._databaseAdapter.setUserColor(userColor); 174 | this._options.userColor = userColor; 175 | } 176 | 177 | setUserName(userName: string): void { 178 | this._databaseAdapter.setUserName(userName); 179 | this._options.userName = userName; 180 | } 181 | 182 | getText(): string { 183 | this._assertReady("getText"); 184 | return this._editorAdapter.getText(); 185 | } 186 | 187 | setText(text: string = ""): void { 188 | this._assertReady("setText"); 189 | this._editorAdapter.setText(text); 190 | this._editorAdapter.setCursor(new Cursor(0, 0)); 191 | } 192 | 193 | clearUndoRedoStack(): void { 194 | this._assertReady("clearUndoRedoStack"); 195 | this._editorClient.clearUndoRedoStack(); 196 | } 197 | 198 | getConfiguration(option: keyof IFirepadClassicConstructorOptions): any { 199 | return this._getOptions(option, () => null); 200 | } 201 | 202 | protected _getOptions( 203 | option: keyof IFirepadClassicConstructorOptions, 204 | getDefault: () => any 205 | ) { 206 | return option in this._options ? this._options[option] : getDefault(); 207 | } 208 | 209 | protected _assertReady(func: string): void { 210 | Utils.validateTruth( 211 | this._ready, 212 | `You must wait for the "ready" event before calling ${func}.` 213 | ); 214 | Utils.validateFalse( 215 | this._zombie, 216 | `You can't use a Firepad after calling dispose()! [called ${func}]` 217 | ); 218 | } 219 | 220 | /** 221 | * Creates a classic Firepad from Monaco editor. 222 | * @param databaseRef - Firebase database Reference object. 223 | * @param editor - Monaco Editor instance. 224 | * @param options - Firepad constructor options (optional). 225 | */ 226 | static fromMonaco( 227 | databaseRef: firebase.database.Reference, 228 | editor: monaco.editor.IStandaloneCodeEditor, 229 | options?: IFirepadClassicConstructorOptions 230 | ) { 231 | return new FirepadClassic(databaseRef, editor, options); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/firepad-monaco.ts: -------------------------------------------------------------------------------- 1 | import * as monaco from "monaco-editor"; 2 | import { v4 as uuid } from "uuid"; 3 | import firebase from "firebase/app"; 4 | 5 | import { IDatabaseAdapter, UserIDType } from "./database-adapter"; 6 | import { FirebaseAdapter } from "./firebase-adapter"; 7 | import { Firepad, IFirepad, IFirepadConstructorOptions } from "./firepad"; 8 | import { MonacoAdapter } from "./monaco-adapter"; 9 | import * as Utils from "./utils"; 10 | import { FirestoreAdapter } from "./firestore-adapter"; 11 | 12 | /** 13 | * Creates a modern Firepad from Monaco editor. 14 | * @param databaseRef - Firebase database Reference path. 15 | * @param editor - Monaco Editor instance. 16 | * @param options - Firepad constructor options (optional). 17 | */ 18 | export function fromMonacoWithFirebase( 19 | databaseRef: string | firebase.database.Reference, 20 | editor: monaco.editor.IStandaloneCodeEditor, 21 | options: Partial = {} 22 | ): IFirepad { 23 | // Initialize constructor options with their default values 24 | const userId: UserIDType = options.userId || uuid(); 25 | const userColor: string = 26 | options.userColor || Utils.colorFromUserId(userId.toString()); 27 | const userName: string = options.userName || userId.toString(); 28 | const defaultText: string = options.defaultText || editor.getValue(); 29 | 30 | let databaseAdapter: IDatabaseAdapter = new FirebaseAdapter( 31 | databaseRef, 32 | userId, 33 | userColor, 34 | userName 35 | ); 36 | 37 | const editorAdapter = new MonacoAdapter(editor, false); 38 | return new Firepad(databaseAdapter, editorAdapter, { 39 | userId, 40 | userName, 41 | userColor, 42 | defaultText, 43 | }); 44 | } 45 | 46 | /** 47 | * Creates a modern Firepad from Monaco editor. 48 | * @param databaseRef - Firestore database document Reference. 49 | * @param editor - Monaco Editor instance. 50 | * @param options - Firepad constructor options (optional). 51 | */ 52 | export function fromMonacoWithFirestore( 53 | databaseRef: firebase.firestore.DocumentReference, //TODO should we support path : string 54 | editor: monaco.editor.IStandaloneCodeEditor, 55 | options: Partial = {} 56 | ): IFirepad { 57 | // Initialize constructor options with their default values 58 | const userId: UserIDType = options.userId || uuid(); 59 | const userColor: string = 60 | options.userColor || Utils.colorFromUserId(userId.toString()); 61 | const userName: string = options.userName || userId.toString(); 62 | const defaultText: string = options.defaultText || editor.getValue(); 63 | 64 | let databaseAdapter: IDatabaseAdapter = new FirestoreAdapter( 65 | databaseRef, 66 | userId, 67 | userColor, 68 | userName 69 | ); 70 | 71 | const editorAdapter = new MonacoAdapter(editor, false); 72 | return new Firepad(databaseAdapter, editorAdapter, { 73 | userId, 74 | userName, 75 | userColor, 76 | defaultText, 77 | }); 78 | } 79 | 80 | /** 81 | * Creates a modern Firepad from Monaco editor. 82 | * @param databaseRef - Firebase database Reference path. 83 | * @param editor - Monaco Editor instance. 84 | * @param options - Firepad constructor options (optional). 85 | */ 86 | export function fromMonaco( 87 | databaseRef: string | firebase.database.Reference, 88 | editor: monaco.editor.IStandaloneCodeEditor, 89 | options: Partial = {} 90 | ): IFirepad { 91 | return fromMonacoWithFirebase(databaseRef, editor, options); 92 | } 93 | -------------------------------------------------------------------------------- /src/firepad.ts: -------------------------------------------------------------------------------- 1 | import { Cursor } from "./cursor"; 2 | import { 3 | DatabaseAdapterEvent, 4 | DatabaseAdapterStateType, 5 | IDatabaseAdapter, 6 | UserIDType, 7 | } from "./database-adapter"; 8 | import { EditorAdapterStateType, IEditorAdapter } from "./editor-adapter"; 9 | import { 10 | EditorClient, 11 | EditorClientEvent, 12 | IEditorClient, 13 | IEditorClientEvent, 14 | } from "./editor-client"; 15 | import { EventEmitter, EventListenerType, IEventEmitter } from "./emitter"; 16 | import * as Utils from "./utils"; 17 | 18 | export enum FirepadEvent { 19 | Ready = "ready", 20 | Synced = "synced", 21 | Undo = "undo", 22 | Redo = "redo", 23 | Error = "error", 24 | } 25 | 26 | export interface IFirepadConstructorOptions { 27 | /** Unique Identifier for current User */ 28 | userId: UserIDType; 29 | /** Unique Hexadecimal color code for current User */ 30 | userColor: string; 31 | /** Name/Short Name of the current User (optional) */ 32 | userName?: string; 33 | /** Default content of Firepad (optional) */ 34 | defaultText?: string; 35 | } 36 | 37 | export interface IFirepad extends Utils.IDisposable { 38 | /** 39 | * Add listener to Firepad. 40 | * @param event - Event name. 41 | * @param listener - Event handler callback. 42 | */ 43 | on( 44 | event: FirepadEvent.Ready | FirepadEvent.Synced, 45 | listener: EventListenerType 46 | ): void; 47 | on( 48 | event: FirepadEvent.Undo | FirepadEvent.Redo, 49 | listener: EventListenerType 50 | ): void; 51 | on(event: FirepadEvent.Error, listener: EventListenerType): void; 52 | on(event: FirepadEvent, listener: EventListenerType): void; 53 | /** 54 | * Remove listener to Firepad. 55 | * @param event - Event name. 56 | * @param listener - Event handler callback. 57 | */ 58 | off( 59 | event: FirepadEvent.Ready | FirepadEvent.Synced, 60 | listener: EventListenerType 61 | ): void; 62 | off( 63 | event: FirepadEvent.Undo | FirepadEvent.Redo, 64 | listener: EventListenerType 65 | ): void; 66 | off(event: FirepadEvent.Error, listener: EventListenerType): void; 67 | off(event: FirepadEvent, listener: EventListenerType): void; 68 | /** 69 | * Tests if any operation has been performed in Firepad. 70 | */ 71 | isHistoryEmpty(): boolean; 72 | /** 73 | * Set User Id to Firepad to distinguish user. 74 | * @param userId - Unique Identifier for current User. 75 | */ 76 | setUserId(userId: UserIDType): void; 77 | /** 78 | * Set User Color to identify current User's cursor or selection. 79 | * @param userColor - Hexadecimal color code. 80 | */ 81 | setUserColor(userColor: string): void; 82 | /** 83 | * Set User Name to mark current user's Cursor. 84 | * @param userName - Name of current user. 85 | */ 86 | setUserName(userName: string): void; 87 | /** 88 | * Returns current content of Firepad. 89 | */ 90 | getText(): string; 91 | /** 92 | * Sets content into Firepad. 93 | * @param text - Text content to set 94 | */ 95 | setText(text: string): void; 96 | /** 97 | * Clears undo redo stack of current Editor model. 98 | */ 99 | clearUndoRedoStack(): void; 100 | /** 101 | * Returns current Firepad configuration. 102 | * @param option - Configuration option (same as constructor). 103 | */ 104 | getConfiguration(option: keyof IFirepadConstructorOptions): any; 105 | } 106 | 107 | export class Firepad implements IFirepad { 108 | protected readonly _options: IFirepadConstructorOptions; 109 | protected readonly _editorClient: IEditorClient; 110 | protected readonly _editorAdapter: IEditorAdapter; 111 | protected readonly _databaseAdapter: IDatabaseAdapter; 112 | 113 | protected _ready: boolean; 114 | protected _zombie: boolean; 115 | protected _emitter: IEventEmitter | null; 116 | 117 | /** 118 | * Creates modern Firepad. 119 | * @param databaseAdapter - Database interface wrapped inside Adapter. 120 | * @param editorAdapter - Editor interface wrapped inside Adapter. 121 | * @param options - Additional construction options. 122 | */ 123 | constructor( 124 | databaseAdapter: IDatabaseAdapter, 125 | editorAdapter: IEditorAdapter, 126 | options: IFirepadConstructorOptions 127 | ) { 128 | /** If not called with `new` operator */ 129 | if (!new.target) { 130 | return new Firepad(databaseAdapter, editorAdapter, options); 131 | } 132 | 133 | this._ready = false; 134 | this._zombie = false; 135 | this._options = options; 136 | 137 | this._databaseAdapter = databaseAdapter; 138 | this._editorAdapter = editorAdapter; 139 | this._editorClient = new EditorClient(databaseAdapter, editorAdapter); 140 | 141 | this._emitter = new EventEmitter([ 142 | FirepadEvent.Ready, 143 | FirepadEvent.Synced, 144 | FirepadEvent.Undo, 145 | FirepadEvent.Redo, 146 | FirepadEvent.Error, 147 | ]); 148 | 149 | this._init(); 150 | } 151 | 152 | protected _init(): void { 153 | this._databaseAdapter.on(DatabaseAdapterEvent.Ready, () => { 154 | this._ready = true; 155 | 156 | const { defaultText } = this._options; 157 | if (defaultText && this.isHistoryEmpty()) { 158 | this.setText(defaultText); 159 | this.clearUndoRedoStack(); 160 | } 161 | 162 | this._trigger(FirepadEvent.Ready, true); 163 | }); 164 | 165 | this._editorClient.on( 166 | EditorClientEvent.Synced, 167 | (synced: boolean | IEditorClientEvent) => { 168 | setTimeout(() => { 169 | this._trigger(FirepadEvent.Synced, synced as boolean); 170 | }); 171 | } 172 | ); 173 | 174 | this._editorClient.on( 175 | EditorClientEvent.Undo, 176 | (undoOperation: string | IEditorClientEvent) => { 177 | setTimeout(() => { 178 | this._trigger(FirepadEvent.Undo, undoOperation as string); 179 | }); 180 | } 181 | ); 182 | 183 | this._editorClient.on( 184 | EditorClientEvent.Redo, 185 | (redoOperation: string | IEditorClientEvent) => { 186 | setTimeout(() => { 187 | this._trigger(FirepadEvent.Redo, redoOperation as string); 188 | }); 189 | } 190 | ); 191 | 192 | this._editorClient.on( 193 | EditorClientEvent.Error, 194 | ( 195 | error: Error | IEditorClientEvent, 196 | operation: string, 197 | state: DatabaseAdapterStateType | EditorAdapterStateType 198 | ) => { 199 | setTimeout(() => { 200 | this._trigger(FirepadEvent.Error, error as Error, operation, state); 201 | }); 202 | } 203 | ); 204 | } 205 | 206 | getConfiguration(option: keyof IFirepadConstructorOptions): any { 207 | return option in this._options ? this._options[option] : null; 208 | } 209 | 210 | on(event: FirepadEvent, listener: EventListenerType): void { 211 | return this._emitter?.on(event, listener); 212 | } 213 | 214 | off(event: FirepadEvent, listener: EventListenerType): void { 215 | return this._emitter?.off(event, listener); 216 | } 217 | 218 | protected _trigger( 219 | event: FirepadEvent.Ready | FirepadEvent.Synced, 220 | eventAttr: boolean 221 | ): void; 222 | protected _trigger( 223 | event: FirepadEvent.Undo | FirepadEvent.Redo, 224 | eventAttr: string 225 | ): void; 226 | protected _trigger( 227 | event: FirepadEvent.Error, 228 | eventAttr: Error, 229 | ...extraArgs: [string, DatabaseAdapterStateType | EditorAdapterStateType] 230 | ): void; 231 | protected _trigger( 232 | event: FirepadEvent, 233 | eventAttr: any, 234 | ...extraArgs: unknown[] 235 | ): void { 236 | return this._emitter?.trigger(event, eventAttr, ...extraArgs); 237 | } 238 | 239 | isHistoryEmpty(): boolean { 240 | this._assertReady("isHistoryEmpty"); 241 | return this._databaseAdapter.isHistoryEmpty(); 242 | } 243 | 244 | setUserId(userId: string | number): void { 245 | this._databaseAdapter.setUserId(userId); 246 | this._options.userId = userId; 247 | } 248 | 249 | setUserColor(userColor: string): void { 250 | this._databaseAdapter.setUserColor(userColor); 251 | this._options.userColor = userColor; 252 | } 253 | 254 | setUserName(userName: string): void { 255 | this._databaseAdapter.setUserName(userName); 256 | this._options.userName = userName; 257 | } 258 | 259 | getText(): string { 260 | this._assertReady("getText"); 261 | return this._editorAdapter.getText(); 262 | } 263 | 264 | setText(text: string = ""): void { 265 | this._assertReady("setText"); 266 | this._editorAdapter.setText(text); 267 | this._editorAdapter.setCursor(new Cursor(0, 0)); 268 | } 269 | 270 | clearUndoRedoStack(): void { 271 | this._assertReady("clearUndoRedoStack"); 272 | this._editorClient.clearUndoRedoStack(); 273 | } 274 | 275 | dispose(): void { 276 | this._zombie = true; 277 | this._databaseAdapter.dispose(); 278 | this._editorAdapter.dispose(); 279 | this._editorClient.dispose(); 280 | 281 | if (this._emitter) { 282 | this._trigger(FirepadEvent.Ready, false); 283 | this._emitter.dispose(); 284 | this._emitter = null; 285 | } 286 | } 287 | 288 | protected _assertReady(func: string): void { 289 | Utils.validateTruth( 290 | this._ready, 291 | `You must wait for the "ready" event before calling ${func}.` 292 | ); 293 | Utils.validateFalse( 294 | this._zombie, 295 | `You can't use a Firepad after calling dispose()! [called ${func}]` 296 | ); 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./cursor"; 2 | export * from "./database-adapter"; 3 | export * from "./editor-adapter"; 4 | export * from "./firebase-adapter"; 5 | export * from "./firepad"; 6 | export * from "./firepad-monaco"; 7 | export * from "./monaco-adapter"; 8 | export * from "./text-operation"; 9 | 10 | export { Firepad as default } from "./firepad"; 11 | -------------------------------------------------------------------------------- /src/operation-meta.ts: -------------------------------------------------------------------------------- 1 | import { ICursor } from "./cursor"; 2 | import { ITextOperation } from "./text-operation"; 3 | 4 | export interface IOperationMeta { 5 | /** 6 | * Return shallow clone of Operation Metadata 7 | */ 8 | clone(): IOperationMeta; 9 | /** 10 | * Return updated Operation Metadata on Inversion of Operation 11 | */ 12 | invert(): IOperationMeta; 13 | /** 14 | * Return updated Operation Metadata on Composition of Operation 15 | * @param other - Operation Metadata from other operation 16 | */ 17 | compose(other: IOperationMeta): IOperationMeta; 18 | /** 19 | * Return updated Operation Metadata on Transformation of Operation 20 | * @param operation - Text Operation. 21 | */ 22 | transform(operation: ITextOperation): IOperationMeta; 23 | /** 24 | * Returns final state of Cursor 25 | */ 26 | getCursor(): ICursor | null; 27 | } 28 | 29 | export class OperationMeta implements IOperationMeta { 30 | protected readonly _cursorBefore: ICursor | null; 31 | protected readonly _cursorAfter: ICursor | null; 32 | 33 | /** 34 | * Creates additional metadata for a Text Operation 35 | * @param cursorBefore - Cursor position before Text Operation is applied 36 | * @param cursorAfter - Cursor position after Text Operation is applied 37 | */ 38 | constructor(cursorBefore: ICursor | null, cursorAfter: ICursor | null) { 39 | this._cursorBefore = cursorBefore; 40 | this._cursorAfter = cursorAfter; 41 | } 42 | 43 | clone(): IOperationMeta { 44 | return new OperationMeta(this._cursorBefore, this._cursorAfter); 45 | } 46 | 47 | invert(): IOperationMeta { 48 | return new OperationMeta(this._cursorAfter, this._cursorBefore); 49 | } 50 | 51 | compose(other: OperationMeta): IOperationMeta { 52 | return new OperationMeta(this._cursorBefore, other._cursorAfter); 53 | } 54 | 55 | transform(operation: ITextOperation): IOperationMeta { 56 | return new OperationMeta( 57 | this._cursorBefore ? this._cursorBefore.transform(operation) : null, 58 | this._cursorAfter ? this._cursorAfter.transform(operation) : null 59 | ); 60 | } 61 | 62 | getCursor(): ICursor | null { 63 | return this._cursorAfter; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/remote-client.ts: -------------------------------------------------------------------------------- 1 | import { ICursor } from "./cursor"; 2 | import { UserIDType } from "./database-adapter"; 3 | import { IEditorAdapter } from "./editor-adapter"; 4 | import { IDisposable } from "./utils"; 5 | 6 | export interface IRemoteClient { 7 | /** Set Cursor/Selection Color for Remote Client */ 8 | setColor(color: string): void; 9 | /** Set Cursor/Selection Owner for Remote Client */ 10 | setUserName(userName: string): void; 11 | /** Update Cursor/Selection position for Remote Client */ 12 | updateCursor(cursor: ICursor): void; 13 | /** Remove Cursor/Selection from Editor */ 14 | removeCursor(): void; 15 | } 16 | 17 | export class RemoteClient implements IRemoteClient { 18 | protected readonly _clientId: UserIDType; 19 | protected readonly _editorAdapter: IEditorAdapter; 20 | 21 | protected _userName: string; 22 | protected _userColor: string; 23 | protected _userCursor: ICursor | null; 24 | protected _mark: IDisposable | null; 25 | 26 | /** 27 | * Creates a Client object for Remote Users. 28 | * @param clientId - Unique Identifier for Remote User. 29 | * @param editorAdapter - Editor instance wrapped in Adapter interface. 30 | */ 31 | constructor(clientId: UserIDType, editorAdapter: IEditorAdapter) { 32 | this._clientId = clientId; 33 | this._editorAdapter = editorAdapter; 34 | } 35 | 36 | setColor(color: string): void { 37 | this._userColor = color; 38 | } 39 | 40 | setUserName(userName: string): void { 41 | this._userName = userName; 42 | } 43 | 44 | updateCursor(cursor: ICursor): void { 45 | this.removeCursor(); 46 | this._userCursor = cursor; 47 | this._mark = this._editorAdapter.setOtherCursor( 48 | this._clientId, 49 | cursor, 50 | this._userColor, 51 | this._userName 52 | ); 53 | } 54 | 55 | removeCursor(): void { 56 | if (this._mark) { 57 | this._mark.dispose(); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/text-op.ts: -------------------------------------------------------------------------------- 1 | import * as Utils from "./utils"; 2 | 3 | export enum TextOptType { 4 | Insert = "insert", 5 | Delete = "delete", 6 | Retain = "retain", 7 | } 8 | 9 | export interface ITextOpAttributes { 10 | [attributeKey: string]: number | boolean | string | symbol; 11 | } 12 | 13 | /** 14 | * Operation are essentially lists of ops. There are three types of ops: 15 | * 16 | * **Retain ops:** Advance the cursor position by a given number of characters. 17 | * Represented by positive ints. 18 | * 19 | * **Insert ops:** Insert a given string at the current cursor position. 20 | * Represented by strings. 21 | * 22 | * **Delete ops:** Delete the next n characters. Represented by negative ints. 23 | */ 24 | export interface ITextOp { 25 | /** 26 | * Number of characters Retained or Deleted. (`null` in case of Insert) 27 | */ 28 | chars: number | null; 29 | /** 30 | * String to be Inserted. (`null` in case of Retain or Delete) 31 | */ 32 | text: string | null; 33 | /** 34 | * Additional Attributes. (Defaults: `null`) 35 | */ 36 | attributes: ITextOpAttributes | null; 37 | 38 | /** 39 | * Tests if it's an Insert Operation. 40 | */ 41 | isInsert(): boolean; 42 | /** 43 | * Tests if it's a Delete Operation. 44 | */ 45 | isDelete(): boolean; 46 | /** 47 | * Tests if it's a Retain Operation. 48 | */ 49 | isRetain(): boolean; 50 | /** 51 | * Tests if two Individual Text Operation equal or not. 52 | * @param other - Another Text Operation. 53 | */ 54 | equals(other: ITextOp): boolean; 55 | /** 56 | * Tests if two Individual Text Operation have Attributes or not. 57 | * @param otherAttributes - Another Text Operation Attributes. 58 | */ 59 | attributesEqual(otherAttributes: ITextOpAttributes | null): boolean; 60 | /** 61 | * Tests if this Individual Text Operation has additional Attributes. 62 | */ 63 | hasEmptyAttributes(): boolean; 64 | /** 65 | * Returns String representation of an Individual Text Operation 66 | */ 67 | toString(): string; 68 | /** 69 | * Returns Primitive value of an Individual Text Operation 70 | */ 71 | valueOf(): string | number | null; 72 | } 73 | 74 | export class TextOp implements ITextOp { 75 | protected readonly _type: TextOptType; 76 | 77 | readonly chars: number | null; 78 | readonly text: string | null; 79 | readonly attributes: ITextOpAttributes | null; 80 | 81 | /** 82 | * Creates an individual Insert Operation 83 | * @param type - Operation Type - Insert 84 | * @param charsOrText - Insert string 85 | * @param attributes - Additional Attributes of the operation 86 | */ 87 | constructor( 88 | type: TextOptType.Insert, 89 | charsOrText: string, 90 | attributes: ITextOpAttributes | null 91 | ); 92 | /** 93 | * Creates an individual Delete Operation 94 | * @param type - Operation Type - Delete 95 | * @param charsOrText - Number of characters to delete 96 | * @param attributes - Additional Attributes of the operation 97 | */ 98 | constructor(type: TextOptType.Delete, charsOrText: number, attributes: null); 99 | /** 100 | * Creates an individual Retain Operation 101 | * @param type - Operation Type - Retain 102 | * @param charsOrText - Number of characters to retain 103 | * @param attributes - Additional Attributes of the operation 104 | */ 105 | constructor( 106 | type: TextOptType.Retain, 107 | charsOrText: number, 108 | attributes: ITextOpAttributes | null 109 | ); 110 | constructor( 111 | type: TextOptType, 112 | charsOrText: number | string, 113 | attributes: ITextOpAttributes | null 114 | ) { 115 | this._type = type; 116 | this.chars = null; 117 | this.text = null; 118 | this.attributes = null; 119 | 120 | switch (type) { 121 | case TextOptType.Insert: { 122 | this.text = charsOrText as string; 123 | this.attributes = attributes || {}; 124 | break; 125 | } 126 | case TextOptType.Delete: { 127 | this.chars = charsOrText as number; 128 | break; 129 | } 130 | case TextOptType.Retain: { 131 | this.chars = charsOrText as number; 132 | this.attributes = attributes || {}; 133 | break; 134 | } 135 | default: 136 | break; 137 | } 138 | } 139 | 140 | isInsert(): boolean { 141 | return this._type === TextOptType.Insert; 142 | } 143 | 144 | isDelete(): boolean { 145 | return this._type === TextOptType.Delete; 146 | } 147 | 148 | isRetain(): boolean { 149 | return this._type === TextOptType.Retain; 150 | } 151 | 152 | equals(other: TextOp): boolean { 153 | return ( 154 | this._type === other._type && 155 | this.text === other.text && 156 | this.chars === other.chars && 157 | this.attributesEqual(other.attributes) 158 | ); 159 | } 160 | 161 | attributesEqual(otherAttributes: ITextOpAttributes | null): boolean { 162 | if (otherAttributes == null || this.attributes == null) { 163 | return this.attributes == otherAttributes; 164 | } 165 | 166 | for (const attr in this.attributes) { 167 | if (this.attributes[attr] !== otherAttributes[attr]) { 168 | return false; 169 | } 170 | } 171 | 172 | for (const attr in otherAttributes) { 173 | if (this.attributes[attr] !== otherAttributes[attr]) { 174 | return false; 175 | } 176 | } 177 | 178 | return true; 179 | } 180 | 181 | hasEmptyAttributes(): boolean { 182 | if (this.attributes == null) { 183 | return true; 184 | } 185 | 186 | return Object.keys(this.attributes).length === 0; 187 | } 188 | 189 | toString(): string { 190 | const text = this.text ? `"${this.text}"` : this.chars; 191 | return `${Utils.capitalizeFirstLetter(this._type)} ${text}`; 192 | } 193 | 194 | valueOf(): string | number | null { 195 | switch (this._type) { 196 | case TextOptType.Insert: { 197 | return this.text!; 198 | } 199 | case TextOptType.Delete: { 200 | return -this.chars!; 201 | } 202 | case TextOptType.Retain: { 203 | return this.chars!; 204 | } 205 | default: 206 | break; 207 | } 208 | 209 | return null; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/undo-manager.ts: -------------------------------------------------------------------------------- 1 | import { ITextOperation, TextOperation } from "./text-operation"; 2 | import * as Utils from "./utils"; 3 | import { IWrappedOperation } from "./wrapped-operation"; 4 | 5 | enum UndoManagerState { 6 | Normal = "normal", 7 | Undoing = "undoing", 8 | Redoing = "redoing", 9 | } 10 | 11 | type UndoManagerCallbackType = (operation?: IWrappedOperation) => void; 12 | 13 | export interface IUndoManager extends Utils.IDisposable { 14 | /** 15 | * Add an operation to the undo or redo stack, depending on the current state 16 | * of the UndoManager. The operation added must be the inverse of the last 17 | * edit. When `compose` is true, compose the operation with the last operation 18 | * unless the last operation was alread pushed on the redo stack or was hidden 19 | * by a newer operation on the undo stack. 20 | */ 21 | add(operation: ITextOperation, compose?: boolean): void; 22 | /** 23 | * Returns last entry in the undo stack if exists. 24 | */ 25 | last(): ITextOperation | null; 26 | /** 27 | * Transform the undo and redo stacks against a operation by another client. 28 | */ 29 | transform(operation: ITextOperation): void; 30 | /** 31 | * Perform an undo by calling a function with the latest operation on the undo 32 | * stack. The function is expected to call the `add` method with the inverse 33 | * of the operation, which pushes the inverse on the redo stack. 34 | */ 35 | performUndo(callback: UndoManagerCallbackType): void; 36 | /** 37 | * Perform a redo by calling a function with the latest operation on the redo 38 | * stack. The function is expected to call the `add` method with the inverse 39 | * of the operation, which pushes the inverse on the undo stack. 40 | */ 41 | performRedo(callback: UndoManagerCallbackType): void; 42 | /** 43 | * Is the undo stack not empty? 44 | */ 45 | canUndo(): boolean; 46 | /** 47 | * Is the redo stack not empty? 48 | */ 49 | canRedo(): boolean; 50 | /** 51 | * Whether the UndoManager is currently performing an undo. 52 | */ 53 | isUndoing(): boolean; 54 | /** 55 | * Whether the UndoManager is currently performing a redo. 56 | */ 57 | isRedoing(): boolean; 58 | } 59 | 60 | export class UndoManager implements IUndoManager { 61 | protected readonly _maxItems: number; 62 | 63 | protected _state: UndoManagerState; 64 | protected _compose: boolean; 65 | protected _undoStack: IWrappedOperation[]; 66 | protected _redoStack: IWrappedOperation[]; 67 | 68 | /** 69 | * Default value `(50)` for maximum number of operation to hold in Undo/Redo stack 70 | */ 71 | protected static readonly MAX_ITEM_IN_STACK: number = 50; 72 | 73 | /** 74 | * Creates an Undo/Redo Stack manager 75 | * @param maxItems - Maximum number of operation to hold in Undo/Redo stack (optional, defaults to `50`) 76 | */ 77 | constructor(maxItems: number = UndoManager.MAX_ITEM_IN_STACK) { 78 | Utils.validateGreater(maxItems, 0); 79 | 80 | this._maxItems = maxItems; 81 | 82 | this._undoStack = []; 83 | this._redoStack = []; 84 | this._compose = true; 85 | this._state = UndoManagerState.Normal; 86 | } 87 | 88 | dispose(): void { 89 | Utils.validateEquality( 90 | this._state, 91 | UndoManagerState.Normal, 92 | "Cannot dispose UndoManager while an undo/redo is in-progress" 93 | ); 94 | 95 | this._undoStack = []; 96 | this._redoStack = []; 97 | } 98 | 99 | protected _addOnNormalState( 100 | operation: IWrappedOperation, 101 | compose: boolean 102 | ): void { 103 | let toPushOperation: IWrappedOperation = operation; 104 | 105 | if (this._compose && compose && this._undoStack.length > 0) { 106 | toPushOperation = operation.compose( 107 | this._undoStack.pop()! 108 | ) as IWrappedOperation; 109 | } 110 | 111 | this._undoStack.push(toPushOperation); 112 | 113 | if (this._undoStack.length > this._maxItems) { 114 | this._undoStack.shift(); 115 | } 116 | 117 | this._compose = true; 118 | this._redoStack = []; 119 | } 120 | 121 | protected _addOnUndoingState(operation: IWrappedOperation): void { 122 | this._redoStack.push(operation); 123 | this._compose = false; 124 | } 125 | 126 | protected _addOnRedoingState(operation: IWrappedOperation): void { 127 | this._undoStack.push(operation); 128 | this._compose = true; 129 | } 130 | 131 | add(operation: ITextOperation, compose: boolean = false): void { 132 | switch (this._state) { 133 | case UndoManagerState.Undoing: { 134 | return this._addOnUndoingState(operation as IWrappedOperation); 135 | } 136 | case UndoManagerState.Redoing: { 137 | return this._addOnRedoingState(operation as IWrappedOperation); 138 | } 139 | case UndoManagerState.Normal: { 140 | return this._addOnNormalState(operation as IWrappedOperation, compose); 141 | } 142 | default: 143 | break; 144 | } 145 | } 146 | 147 | last(): ITextOperation | null { 148 | if (this._undoStack.length === 0) { 149 | return null; 150 | } 151 | 152 | return this._undoStack[this._undoStack.length - 1].clone(); 153 | } 154 | 155 | protected _transformStack( 156 | stack: ITextOperation[], 157 | operation: TextOperation 158 | ): IWrappedOperation[] { 159 | const newStack: ITextOperation[] = []; 160 | const reverseStack: TextOperation[] = [ 161 | ...stack, 162 | ].reverse() as TextOperation[]; 163 | 164 | for (const stackOp of reverseStack) { 165 | const pair = stackOp.transform(operation); 166 | 167 | if (!pair[0].isNoop()) { 168 | newStack.push(pair[0]); 169 | } 170 | 171 | operation = pair[1] as TextOperation; 172 | } 173 | 174 | return newStack.reverse() as IWrappedOperation[]; 175 | } 176 | 177 | transform(operation: TextOperation): void { 178 | this._undoStack = this._transformStack(this._undoStack, operation); 179 | this._redoStack = this._transformStack(this._redoStack, operation); 180 | } 181 | 182 | performUndo(callback: UndoManagerCallbackType): void { 183 | this._state = UndoManagerState.Undoing; 184 | 185 | Utils.validateInEquality(this._undoStack.length, 0, "undo not possible"); 186 | 187 | callback(this._undoStack.pop()); 188 | this._state = UndoManagerState.Normal; 189 | } 190 | 191 | performRedo(callback: UndoManagerCallbackType): void { 192 | this._state = UndoManagerState.Redoing; 193 | 194 | Utils.validateInEquality(this._redoStack.length, 0, "redo not possible"); 195 | 196 | callback(this._redoStack.pop()); 197 | this._state = UndoManagerState.Normal; 198 | } 199 | 200 | canUndo(): boolean { 201 | return this._undoStack.length !== 0; 202 | } 203 | 204 | canRedo(): boolean { 205 | return this._redoStack.length !== 0; 206 | } 207 | 208 | isUndoing(): boolean { 209 | return this._state === UndoManagerState.Undoing; 210 | } 211 | 212 | isRedoing(): boolean { 213 | return this._state === UndoManagerState.Redoing; 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** Function with no parameter and no return value. */ 2 | export function noop(): void {} 3 | export type VoidFunctionType = typeof noop; 4 | 5 | /** 6 | * Disposable Interface. 7 | * Applicable to the Interfaces/Objects that requires cleanup after usage. 8 | */ 9 | export interface IDisposable { 10 | /** Cleanup Function */ 11 | dispose(): void; 12 | } 13 | 14 | /** 15 | * End Of Line Sequences for Editor Content. 16 | */ 17 | export enum EndOfLineSequence { 18 | LF = "\n", 19 | CRLF = "\r\n", 20 | } 21 | 22 | /** 23 | * Custom Error Definitions 24 | */ 25 | 26 | /** 27 | * Validation Error: Assertion for data validation failed. 28 | */ 29 | class ValidationError extends Error { 30 | readonly name: string = "Validation Failed"; 31 | } 32 | 33 | /** 34 | * No-op Error: Unexpected method call without any executable code. 35 | */ 36 | class NoopError extends Error { 37 | readonly name: string = "No-op Encountered"; 38 | } 39 | 40 | /** 41 | * Invalid Operation Error: Executing an Invalid Operation. 42 | */ 43 | class InvalidOperationError extends Error { 44 | readonly name: string = "Invalid Operation Encountered"; 45 | } 46 | 47 | /** 48 | * Invalid Operation Order Error: Executing a set of Operations in incorrect order. 49 | */ 50 | class InvalidOperationOrderError extends Error { 51 | readonly name: string = "Invalid Order of Operations Encountered"; 52 | } 53 | 54 | /** 55 | * Invalid Event Error: Triggering or Listening to an Invalid Event. 56 | */ 57 | class InvalidEventError extends Error { 58 | readonly name: string = "Invalid Event Encountered"; 59 | } 60 | 61 | /** 62 | * Database Transaction Error: Database Transaction Failure. 63 | */ 64 | class DatabaseTransactionError extends Error { 65 | readonly name: string = "Transaction Failed"; 66 | } 67 | 68 | /** 69 | * Common Utility Methods 70 | */ 71 | 72 | /** 73 | * Validates if the incoming parameter is an Integer. 74 | * @param n - Parameter for validation. 75 | * @param err - Custom Error Message. 76 | */ 77 | export function validateInteger(n: number, err?: string): void { 78 | if (!Number.isInteger(n)) { 79 | throw new ValidationError(err || "Expected an integer value"); 80 | } 81 | } 82 | 83 | /** 84 | * Validates if the incoming parameter is a Non-negative Integer. 85 | * @param n - Parameter for validation. 86 | * @param err - Custom Error Message. 87 | */ 88 | export function validateNonNegativeInteger(n: number, err?: string): void { 89 | validateInteger(n, err); 90 | 91 | if (n < 0) { 92 | throw new ValidationError(err || "Expected a non-negative integer value"); 93 | } 94 | } 95 | 96 | /** 97 | * Validates if the incoming parameter is a String. 98 | * @param n - Parameter for validation. 99 | * @param err - Custom Error Message. 100 | */ 101 | export function validateString(n: string, err?: string): void { 102 | if (typeof n !== "string") { 103 | throw new ValidationError(err || "Expected a string value"); 104 | } 105 | } 106 | 107 | /** 108 | * Validates if the incoming parameters are Equal. 109 | * @param first - First Parameter for validation. 110 | * @param second - Second Parameter for validation. 111 | * @param err - Custom Error Message. 112 | */ 113 | export function validateEquality( 114 | first: string | number | boolean | symbol, 115 | second: string | number | boolean | symbol, 116 | err?: string 117 | ) { 118 | if (first !== second) { 119 | throw new ValidationError( 120 | err || `Expected ${first.toString()} to be equal to ${second.toString()}.` 121 | ); 122 | } 123 | } 124 | 125 | /** 126 | * Validates if the incoming parameters are Not Equal. 127 | * @param first - First Parameter for validation. 128 | * @param second - Second Parameter for validation. 129 | * @param err - Custom Error Message. 130 | */ 131 | export function validateInEquality( 132 | first: string | number | boolean | symbol, 133 | second: string | number | boolean | symbol, 134 | err?: string 135 | ) { 136 | if (first === second) { 137 | throw new ValidationError( 138 | err || 139 | `Expected ${first.toString()} to not be equal to ${second.toString()}.` 140 | ); 141 | } 142 | } 143 | 144 | /** 145 | * Validates if the incoming first parameter is Lesser Than or Equals to the incoming second parameter. 146 | * @param first - First Parameter for validation. 147 | * @param second - Second Parameter for validation. 148 | * @param err - Custom Error Message. 149 | */ 150 | export function validateLessOrEqual( 151 | first: number, 152 | second: number, 153 | err?: string 154 | ) { 155 | if (first > second) { 156 | throw new ValidationError( 157 | err || `Expected ${first} to be less than or equal to ${second}.` 158 | ); 159 | } 160 | } 161 | 162 | /** 163 | * Validates if the incoming first parameter Greater Than the incoming second parameter. 164 | * @param first - First Parameter for validation. 165 | * @param second - Second Parameter for validation. 166 | * @param err - Custom Error Message. 167 | */ 168 | export function validateGreater(first: number, second: number, err?: string) { 169 | if (first <= second) { 170 | throw new ValidationError( 171 | err || `Expected ${first} to be greater than ${second}.` 172 | ); 173 | } 174 | } 175 | 176 | /** 177 | * Validates if the incoming parameter is True. 178 | * @param arg - Parameter for validation. 179 | * @param err - Custom Error Message. 180 | */ 181 | export function validateTruth(arg: boolean | null | undefined, err?: string) { 182 | if (arg == null || arg === false) { 183 | throw new ValidationError(err || "Expected a Truth value"); 184 | } 185 | } 186 | 187 | /** 188 | * Validates if the incoming parameter is False. 189 | * @param arg - Parameter for validation. 190 | * @param err - Custom Error Message. 191 | */ 192 | export function validateFalse(arg: boolean | null | undefined, err?: string) { 193 | if (arg === true) { 194 | throw new ValidationError(err || "Expected a False value"); 195 | } 196 | } 197 | 198 | /** 199 | * Evokes error when called. 200 | * This should be placed before any in-accessable code to ensure not to get called. 201 | * @param err - Custom Error Message. 202 | */ 203 | export function shouldNotGetCalled(err?: string): void { 204 | throw new NoopError( 205 | err || "This method should not get called or has no operation to perform" 206 | ); 207 | } 208 | 209 | /** 210 | * Evokes error when called. 211 | * This should be placed before any invalid event listener. 212 | * @param event - Name of the Event. 213 | * @param err - Custom Error Message. 214 | */ 215 | export function shouldNotBeListenedTo(event: string, err?: string): void { 216 | throw new InvalidEventError( 217 | err || `Unknown event ${event} to add/remove listener for given object` 218 | ); 219 | } 220 | 221 | /** 222 | * Evokes error when called. 223 | * This function should be called when a set of Operation is recieved in an incorrect order. 224 | * @param err - Custom Error Message. 225 | */ 226 | export function shouldNotBeComposedOrApplied(err?: string): void { 227 | throw new InvalidOperationOrderError( 228 | err || 229 | "Invalid order of operation recieved that cannot be composed or applied" 230 | ); 231 | } 232 | 233 | /** 234 | * Evokes error when called. 235 | * This function should be called when Database Transaction fails. 236 | * @param err - Custom Error Message. 237 | */ 238 | export function onFailedDatabaseTransaction(err?: string): void { 239 | throw new DatabaseTransactionError(err || "Transaction Failure!"); 240 | } 241 | 242 | /** 243 | * Evokes error when called. 244 | * This function should be called when an invalid Operation is recieved. 245 | * @param err - Custom Error Message. 246 | */ 247 | export function onInvalidOperationRecieve(err?: string): void { 248 | throw new InvalidOperationError(err || "Invalid operation recieved!"); 249 | } 250 | 251 | /** 252 | * Converts color code from RGB format to Hexadecimal format. 253 | * @param red - Color depth of Red pigment (0 to 1). 254 | * @param blue - Color depth of Blue pigment (0 to 1). 255 | * @param green - Color depth of Green pigment (0 to 1). 256 | */ 257 | export function rgbToHex(red: number, blue: number, green: number): string { 258 | const depth = [red, blue, green].map((color) => 259 | Math.round(255 * color) 260 | .toString(16) 261 | .padStart(2, "0") 262 | ); 263 | 264 | return ["#", ...depth].join(""); 265 | } 266 | 267 | /** 268 | * Converts color code from Hexadecimal format to RGB format. 269 | * @param hex - Hexadecimal represenation of color. 270 | */ 271 | export function hexToRgb(hex: string): Array { 272 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 273 | 274 | if (!result) { 275 | return [0, 0, 0]; 276 | } 277 | 278 | return [ 279 | parseInt(result[1], 16), 280 | parseInt(result[2], 16), 281 | parseInt(result[3], 16), 282 | ]; 283 | } 284 | 285 | function hueToRgb( 286 | degree: number, 287 | percentage1: number, 288 | percentage2: number 289 | ): number { 290 | if (degree < 0) { 291 | degree += 1; 292 | } 293 | 294 | if (degree > 1) { 295 | degree -= 1; 296 | } 297 | 298 | if (6 * degree < 1) { 299 | return percentage1 + (percentage2 - percentage1) * 6 * degree; 300 | } 301 | 302 | if (2 * degree < 1) { 303 | return percentage2; 304 | } 305 | 306 | if (3 * degree < 2) { 307 | return percentage1 + (percentage2 - percentage1) * 6 * (2 / 3 - degree); 308 | } 309 | 310 | return percentage1; 311 | } 312 | 313 | /** 314 | * Converts color code from HSL format to Hexadecimal format. 315 | * @param hue - Hue of the color. 316 | * @param saturation - Saturation of the color. 317 | * @param lightness - Brightness of the color. 318 | */ 319 | export function hslToHex( 320 | hue: number, 321 | saturation: number, 322 | lightness: number 323 | ): string { 324 | if (saturation === 0) { 325 | return rgbToHex(lightness, lightness, lightness); 326 | } 327 | 328 | const percentage2 = 329 | lightness < 0.5 330 | ? lightness * (1 + saturation) 331 | : lightness + saturation - saturation * lightness; 332 | const percentage1 = 2 * lightness - percentage2; 333 | 334 | return rgbToHex( 335 | hueToRgb(hue + 1 / 3, percentage1, percentage2), 336 | hueToRgb(hue, percentage1, percentage2), 337 | hueToRgb(hue - 1 / 3, percentage1, percentage2) 338 | ); 339 | } 340 | 341 | /** 342 | * Generates a color code based on User Id provided. 343 | * This is used as default value if explicit color is not given. 344 | * @param userId - String representation of the User Id. 345 | */ 346 | export function colorFromUserId(userId: string) { 347 | let a = 1; 348 | 349 | for (let i = 0; i < userId.length; i++) { 350 | a = (17 * (a + userId.charCodeAt(i))) % 360; 351 | } 352 | 353 | const hue = a / 360; 354 | 355 | return hslToHex(hue, 1, 0.75); 356 | } 357 | 358 | /** 359 | * Capitalises first letter of the given text. 360 | * @param text - Incoming Text segment. 361 | */ 362 | export function capitalizeFirstLetter(text: string): string { 363 | return text.charAt(0).toUpperCase() + text.slice(1); 364 | } 365 | -------------------------------------------------------------------------------- /src/wrapped-operation.ts: -------------------------------------------------------------------------------- 1 | import { ICursor } from "./cursor"; 2 | import { IOperationMeta } from "./operation-meta"; 3 | import { ITextOp, ITextOpAttributes } from "./text-op"; 4 | import { ITextOperation, TextOperationType } from "./text-operation"; 5 | 6 | /** 7 | * A WrappedOperation contains an operation and corresponing metadata (cursor positions). 8 | */ 9 | export interface IWrappedOperation extends ITextOperation { 10 | /** 11 | * Returns final state of Cursor 12 | */ 13 | getCursor(): ICursor | null; 14 | /** 15 | * Returns a clone of Text Operation 16 | */ 17 | getOperation(): ITextOperation; 18 | } 19 | 20 | export class WrappedOperation implements IWrappedOperation { 21 | protected readonly _operation: ITextOperation; 22 | protected readonly _metadata: IOperationMeta | null; 23 | 24 | /** 25 | * Wraps Text Operation with additional Operation Metadata. 26 | * @param operation - Text Operation to wrap. 27 | * @param metadata - Additional Operation Metadata (optional). 28 | */ 29 | constructor( 30 | operation: ITextOperation, 31 | metadata: IOperationMeta | null = null 32 | ) { 33 | this._operation = operation; 34 | this._metadata = metadata; 35 | } 36 | 37 | isWrappedOperation(): boolean { 38 | return true; 39 | } 40 | 41 | getOps(): ITextOp[] { 42 | return this._operation.getOps(); 43 | } 44 | 45 | getCursor(): ICursor | null { 46 | if (!this._metadata) { 47 | return null; 48 | } 49 | 50 | return this._metadata.getCursor(); 51 | } 52 | 53 | getOperation(): ITextOperation { 54 | return this._operation.clone(); 55 | } 56 | 57 | retain(n: number, attributes: ITextOpAttributes | null): WrappedOperation { 58 | this._operation.retain(n, attributes); 59 | return this; 60 | } 61 | 62 | insert(str: string, attributes: ITextOpAttributes | null): WrappedOperation { 63 | this._operation.insert(str, attributes); 64 | return this; 65 | } 66 | 67 | delete(n: string | number): WrappedOperation { 68 | this._operation.delete(n); 69 | return this; 70 | } 71 | 72 | isNoop(): boolean { 73 | return this._operation.isNoop(); 74 | } 75 | 76 | clone(): WrappedOperation { 77 | return new WrappedOperation( 78 | this._operation.clone(), 79 | this._metadata?.clone() 80 | ); 81 | } 82 | 83 | apply( 84 | prevContent: string, 85 | prevAttributes?: ITextOpAttributes[], 86 | attributes?: ITextOpAttributes[] 87 | ): string { 88 | return this._operation.apply(prevContent, prevAttributes, attributes); 89 | } 90 | 91 | invert(content: string): WrappedOperation { 92 | return new WrappedOperation( 93 | this._operation.invert(content), 94 | this._metadata?.invert() 95 | ); 96 | } 97 | 98 | protected _getWrappedOperation(operation: ITextOperation): WrappedOperation { 99 | if (!operation.isWrappedOperation()) { 100 | return new WrappedOperation(operation); 101 | } 102 | 103 | return operation as WrappedOperation; 104 | } 105 | 106 | equals(other: ITextOperation): boolean { 107 | const wrappedOther = this._getWrappedOperation(other); 108 | return this._operation.equals(wrappedOther._operation); 109 | } 110 | 111 | transform(other: ITextOperation): [WrappedOperation, WrappedOperation] { 112 | const wrappedOther = this._getWrappedOperation(other); 113 | const [pair0, pair1] = this._operation.transform(wrappedOther._operation); 114 | 115 | const wrappedPair0 = new WrappedOperation( 116 | pair0, 117 | this._metadata?.transform(wrappedOther._operation) 118 | ); 119 | const wrappedPair1 = new WrappedOperation( 120 | pair1, 121 | wrappedOther._metadata?.transform(this._operation) 122 | ); 123 | 124 | return [wrappedPair0, wrappedPair1]; 125 | } 126 | 127 | canMergeWith(other: ITextOperation): boolean { 128 | const wrappedOther = this._getWrappedOperation(other); 129 | return this._operation.canMergeWith(wrappedOther._operation); 130 | } 131 | 132 | protected _composeMeta( 133 | otherMetadata: IOperationMeta | null 134 | ): IOperationMeta | null { 135 | if (!this._metadata) { 136 | return otherMetadata; 137 | } 138 | 139 | if (!otherMetadata) { 140 | return this._metadata; 141 | } 142 | 143 | return this._metadata.compose(otherMetadata); 144 | } 145 | 146 | compose(other: ITextOperation): WrappedOperation { 147 | const wrappedOther = this._getWrappedOperation(other); 148 | 149 | return new WrappedOperation( 150 | this._operation.compose(wrappedOther._operation), 151 | this._composeMeta(wrappedOther._metadata) 152 | ); 153 | } 154 | 155 | shouldBeComposedWith(other: ITextOperation): boolean { 156 | const wrappedOther = this._getWrappedOperation(other); 157 | return this._operation.shouldBeComposedWith(wrappedOther._operation); 158 | } 159 | 160 | shouldBeComposedWithInverted(other: ITextOperation): boolean { 161 | const wrappedOther = this._getWrappedOperation(other); 162 | return this._operation.shouldBeComposedWithInverted( 163 | wrappedOther._operation 164 | ); 165 | } 166 | 167 | toString(): string { 168 | return this._operation.toString(); 169 | } 170 | 171 | toJSON(): TextOperationType { 172 | return this._operation.toJSON(); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /test/__snapshots__/database-adapter.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Database Adapter should only listen to finite number of events 1`] = ` 4 | Object { 5 | "Acknowledge": "ack", 6 | "CursorChange": "cursor", 7 | "Error": "error", 8 | "InitialRevision": "initialRevision", 9 | "Operation": "operation", 10 | "Ready": "ready", 11 | "Retry": "retry", 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /test/__snapshots__/editor-adapter.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Editor Adapter should only listen to finite number of events 1`] = ` 4 | Object { 5 | "Blur": "blur", 6 | "Change": "change", 7 | "CursorActivity": "cursorActivity", 8 | "Error": "error", 9 | "Focus": "focus", 10 | "Redo": "redo", 11 | "Undo": "undo", 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /test/client.spec.ts: -------------------------------------------------------------------------------- 1 | import { Client, ClientEvent, IClient } from "../src/client"; 2 | import { EventListenerType } from "../src/emitter"; 3 | import { ITextOperation, TextOperation } from "../src/text-operation"; 4 | 5 | describe("Client", () => { 6 | let client: IClient; 7 | let applyOperationStub: EventListenerType; 8 | let eventListenerStub: EventListenerType; 9 | let sendOperationStub: EventListenerType; 10 | 11 | beforeAll(() => { 12 | applyOperationStub = jest.fn(); 13 | eventListenerStub = jest.fn(); 14 | sendOperationStub = jest.fn(); 15 | }); 16 | 17 | beforeEach(() => { 18 | client = new Client(); 19 | }); 20 | 21 | afterEach(() => { 22 | client.dispose(); 23 | jest.clearAllMocks(); 24 | }); 25 | 26 | afterAll(() => { 27 | jest.resetAllMocks(); 28 | }); 29 | 30 | describe("#on", () => { 31 | it("should attach event listener to emitter for valid event", () => { 32 | const fn = () => client.on(ClientEvent.ApplyOperation, eventListenerStub); 33 | expect(fn).not.toThrowError(); 34 | }); 35 | }); 36 | 37 | describe("#off", () => { 38 | it("should detach event listener to emitter for valid event", () => { 39 | const fn = () => 40 | client.off(ClientEvent.ApplyOperation, eventListenerStub); 41 | expect(fn).not.toThrowError(); 42 | }); 43 | }); 44 | 45 | describe("#isSynchronized", () => { 46 | it("should start with `Synchronized` state", () => { 47 | expect(client.isSynchronized()).toEqual(true); 48 | }); 49 | }); 50 | 51 | describe("#applyClient", () => { 52 | it("should transition to `AwaitingConfirm` state on receiving operation from document in `Synchronized` state", () => { 53 | client.applyClient(new TextOperation()); 54 | expect(client.isAwaitingConfirm()).toEqual(true); 55 | }); 56 | 57 | it("should send operation to server on receiving operation from document in `Synchronized` state", (done) => { 58 | client.on(ClientEvent.SendOperation, (operation) => { 59 | sendOperationStub(operation); 60 | done(); 61 | }); 62 | 63 | const operation = new TextOperation(); 64 | client.applyClient(operation); 65 | expect(sendOperationStub).toHaveBeenCalledWith(operation); 66 | }); 67 | 68 | it("should transition to `AwaitingWithBuffer` state on receiving operation from document in `AwaitingConfirm` state", () => { 69 | client.applyClient(new TextOperation()); 70 | client.applyClient(new TextOperation()); 71 | expect(client.isAwaitingWithBuffer()).toEqual(true); 72 | }); 73 | 74 | it("should stay in `AwaitingWithBuffer` state on receiving operation from document in `AwaitingWithBuffer` state", () => { 75 | client.applyClient(new TextOperation()); 76 | client.applyClient(new TextOperation()); 77 | client.applyClient(new TextOperation()); 78 | expect(client.isAwaitingWithBuffer()).toEqual(true); 79 | }); 80 | }); 81 | 82 | describe("#applyServer", () => { 83 | it("should stay in `Synchronized` state on receiving operation from server in `Synchronized` state", () => { 84 | client.applyServer(new TextOperation()); 85 | expect(client.isSynchronized()).toEqual(true); 86 | }); 87 | 88 | it("should apply changes to document on receiving operation from server in `Synchronized` state", (done) => { 89 | client.on(ClientEvent.ApplyOperation, (operation) => { 90 | applyOperationStub(operation); 91 | done(); 92 | }); 93 | 94 | const operation = new TextOperation(); 95 | client.applyServer(operation); 96 | expect(applyOperationStub).toHaveBeenCalledWith(operation); 97 | }); 98 | 99 | it("should stay in `AwaitingConfirm` state on receiving operation from server in `AwaitingConfirm` state", () => { 100 | client.applyClient(new TextOperation()); 101 | client.applyServer(new TextOperation()); 102 | expect(client.isAwaitingConfirm()).toEqual(true); 103 | }); 104 | 105 | it("should apply changes to document on receiving operation from server in `AwaitingConfirm` state", (done) => { 106 | client.on(ClientEvent.ApplyOperation, (operation) => { 107 | applyOperationStub(operation); 108 | done(); 109 | }); 110 | 111 | client.applyClient(new TextOperation()); 112 | const operation = new TextOperation(); 113 | client.applyServer(operation); 114 | expect(applyOperationStub).toHaveBeenCalledWith(operation); 115 | }); 116 | 117 | it("should stay in `AwaitingWithBuffer` state on receiving operation from server in `AwaitingWithBuffer` state", () => { 118 | client.applyClient(new TextOperation()); 119 | client.applyClient(new TextOperation()); 120 | client.applyServer(new TextOperation()); 121 | expect(client.isAwaitingWithBuffer()).toEqual(true); 122 | }); 123 | 124 | it("should apply changes to document on receiving operation from server in `AwaitingWithBuffer` state", (done) => { 125 | client.on(ClientEvent.ApplyOperation, (operation) => { 126 | applyOperationStub(operation); 127 | done(); 128 | }); 129 | 130 | client.applyClient(new TextOperation()); 131 | client.applyClient(new TextOperation()); 132 | const operation = new TextOperation(); 133 | client.applyServer(operation); 134 | expect(applyOperationStub).toHaveBeenCalledWith(operation); 135 | }); 136 | }); 137 | 138 | describe("#serverAck", () => { 139 | it("should throw error if called in `Synchronized` state", () => { 140 | const fn = () => client.serverAck(); 141 | expect(fn).toThrowError(); 142 | }); 143 | 144 | it("should transition to `Synchronized` state on receiving acknowledgement from document in `AwaitingConfirm` state", () => { 145 | client.applyClient(new TextOperation()); 146 | client.serverAck(); 147 | expect(client.isSynchronized()).toEqual(true); 148 | }); 149 | 150 | it("should transition to `AwaitingConfirm` state on receiving acknowledgement from server in `AwaitingWithBuffer` state", () => { 151 | client.applyClient(new TextOperation()); 152 | client.applyClient(new TextOperation()); 153 | client.serverAck(); 154 | expect(client.isAwaitingConfirm()).toEqual(true); 155 | }); 156 | 157 | it("should send outstanding operation to server on receiving acknowledgement from document in `AwaitingWithBuffer` state", (done) => { 158 | client.on(ClientEvent.SendOperation, (operation) => { 159 | sendOperationStub(operation); 160 | }); 161 | 162 | const operation = new TextOperation(); 163 | client.applyClient(new TextOperation()); 164 | 165 | client.on(ClientEvent.SendOperation, () => { 166 | done(); 167 | }); 168 | 169 | client.applyClient(operation); 170 | client.serverAck(); 171 | expect(sendOperationStub).toHaveBeenNthCalledWith(2, operation); 172 | }); 173 | }); 174 | 175 | describe("#serverRetry", () => { 176 | it("should throw error if called in `Synchronized` state", () => { 177 | const fn = () => client.serverRetry(); 178 | expect(fn).toThrowError(); 179 | }); 180 | 181 | it("should stay in `AwaitingConfirm` state on receiving error from server in `AwaitingConfirm` state", () => { 182 | client.applyClient(new TextOperation()); 183 | client.serverRetry(); 184 | expect(client.isAwaitingConfirm()).toEqual(true); 185 | }); 186 | 187 | it("should resend operation on receiving error from server in `AwaitingConfirm` state", (done) => { 188 | client.on(ClientEvent.SendOperation, (operation) => { 189 | sendOperationStub(operation); 190 | }); 191 | 192 | const operation = new TextOperation(); 193 | client.applyClient(operation); 194 | 195 | client.on(ClientEvent.SendOperation, () => { 196 | done(); 197 | }); 198 | 199 | client.serverRetry(); 200 | expect(sendOperationStub).toHaveBeenNthCalledWith(2, operation); 201 | }); 202 | 203 | it("should resend operation on receiving error from server in `AwaitingWithBuffer` state", (done) => { 204 | client.on(ClientEvent.SendOperation, (operation) => { 205 | sendOperationStub(operation); 206 | }); 207 | 208 | client.applyClient(new TextOperation()); 209 | const operation = new TextOperation(); 210 | client.applyClient(operation); 211 | 212 | client.on(ClientEvent.SendOperation, () => { 213 | done(); 214 | }); 215 | 216 | client.serverRetry(); 217 | expect(sendOperationStub).toHaveBeenNthCalledWith(2, operation); 218 | }); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /test/cursor.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cursor } from "../src/cursor"; 2 | import { TextOperation } from "../src/text-operation"; 3 | 4 | describe("Cursor", () => { 5 | describe(".fromJSON", () => { 6 | it("should create Cursor object from JSON", () => { 7 | const cursorData = { position: 5, selectionEnd: 9 }; 8 | const cursor = Cursor.fromJSON(cursorData); 9 | expect(cursor).toEqual(new Cursor(5, 9)); 10 | }); 11 | }); 12 | 13 | describe("#equals", () => { 14 | it("should check for equality with another cursor with same co-ordinates", () => { 15 | const cursor = new Cursor(5, 9); 16 | const otherCursor = new Cursor(5, 9); 17 | expect(cursor.equals(otherCursor)).toEqual(true); 18 | }); 19 | 20 | it("should check for equality with another cursor with different co-ordinates", () => { 21 | const cursor = new Cursor(5, 9); 22 | const otherCursor = new Cursor(15, 18); 23 | expect(cursor.equals(otherCursor)).toEqual(false); 24 | }); 25 | 26 | it("should check for equality with invalid value", () => { 27 | const cursor = new Cursor(5, 9); 28 | expect(cursor.equals(null)).toEqual(false); 29 | }); 30 | }); 31 | 32 | describe("#compose", () => { 33 | it("should return final cursor", () => { 34 | const cursor = new Cursor(5, 9); 35 | const nextCursor = new Cursor(15, 18); 36 | expect(cursor.compose(nextCursor)).toEqual(nextCursor); 37 | }); 38 | }); 39 | 40 | describe("#transform", () => { 41 | it("should update cursor based on incoming retain operation", () => { 42 | const cursor = new Cursor(0, 0); 43 | const operation = new TextOperation().retain(5, null); 44 | const nextCursor = cursor.transform(operation); 45 | expect(nextCursor).toEqual(new Cursor(0, 0)); 46 | }); 47 | 48 | it("should update cursor based on incoming insert operation", () => { 49 | const cursor = new Cursor(0, 1); 50 | const operation = new TextOperation().insert("Hello World", null); // 11 letters inserted. 51 | const nextCursor = cursor.transform(operation); 52 | expect(nextCursor).toEqual(new Cursor(11, 12)); 53 | }); 54 | 55 | it("should update cursor based on incoming delete operation", () => { 56 | const cursor = new Cursor(12, 11); 57 | const operation = new TextOperation().delete("Hello World"); // 11 letters deleted. 58 | const nextCursor = cursor.transform(operation); 59 | expect(nextCursor).toEqual(new Cursor(1, 0)); 60 | }); 61 | }); 62 | 63 | describe("#toJSON", () => { 64 | it("should convert Cursor object into JSON", () => { 65 | const cursor = new Cursor(6, 13); 66 | expect(cursor.toJSON()).toEqual({ position: 6, selectionEnd: 13 }); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /test/database-adapter.spec.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseAdapterEvent } from "../src/database-adapter"; 2 | 3 | describe("Database Adapter", () => { 4 | it("should only listen to finite number of events", () => { 5 | expect(DatabaseAdapterEvent).toMatchSnapshot(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /test/editor-adapter.spec.ts: -------------------------------------------------------------------------------- 1 | import { EditorAdapterEvent } from "../src/editor-adapter"; 2 | 3 | describe("Editor Adapter", () => { 4 | it("should only listen to finite number of events", () => { 5 | expect(EditorAdapterEvent).toMatchSnapshot(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /test/emitter.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventType, 3 | EventEmitter, 4 | EventListenerType, 5 | IEventEmitter, 6 | IEvent, 7 | } from "../src/emitter"; 8 | 9 | describe("Emitter", () => { 10 | let event: EventType; 11 | let emitter: IEventEmitter; 12 | let eventListenerStub: EventListenerType; 13 | 14 | beforeAll(() => { 15 | event = "Some Event"; 16 | eventListenerStub = jest.fn(); 17 | }); 18 | 19 | beforeEach(() => { 20 | emitter = new EventEmitter([event]); 21 | }); 22 | 23 | afterEach(() => { 24 | emitter.dispose(); 25 | jest.resetAllMocks(); 26 | }); 27 | 28 | describe("#on", () => { 29 | it("should attach event listener to emitter for valid event", () => { 30 | const fn = () => emitter.on(event, eventListenerStub); 31 | expect(fn).not.toThrowError(); 32 | }); 33 | 34 | it("should throw error for invalid event", () => { 35 | const otherEvent = "Some Other Event"; 36 | const fn = () => emitter.on(otherEvent, eventListenerStub); 37 | expect(fn).toThrowError(); 38 | }); 39 | }); 40 | 41 | describe("#off", () => { 42 | it("should detach event listener to emitter for valid event", () => { 43 | const fn = () => emitter.off(event, eventListenerStub); 44 | expect(fn).not.toThrowError(); 45 | }); 46 | 47 | it("should throw error for invalid event", () => { 48 | const otherEvent = "Some Other Event"; 49 | const fn = () => emitter.off(otherEvent, eventListenerStub); 50 | expect(fn).toThrowError(); 51 | }); 52 | }); 53 | 54 | describe("#trigger", () => { 55 | it("should invoke event listener for given event", () => { 56 | const eventAtrr = "Some Event Details"; 57 | emitter.on(event, eventListenerStub); 58 | emitter.trigger(event, eventAtrr); 59 | expect(eventListenerStub).toHaveBeenCalledWith(eventAtrr); 60 | }); 61 | 62 | it("should not invoke event listener after detachment", () => { 63 | const eventAtrr = "Some Event Details"; 64 | emitter.on(event, eventListenerStub); 65 | emitter.off(event, eventListenerStub); 66 | emitter.trigger(event, eventAtrr); 67 | expect(eventListenerStub).not.toHaveBeenCalled(); 68 | }); 69 | 70 | it("should not throw error if no listener is attached", () => { 71 | const eventAtrr = "Some Event Details"; 72 | const fn = () => emitter.trigger(event, eventAtrr); 73 | expect(fn).not.toThrowError(); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /test/factory/database-adapter.factory.ts: -------------------------------------------------------------------------------- 1 | import firebase from "firebase/app"; 2 | import { ICursor } from "../../src/cursor"; 3 | import { 4 | DatabaseAdapterCallbackType, 5 | DatabaseAdapterEvent, 6 | IDatabaseAdapter, 7 | IDatabaseAdapterEvent, 8 | } from "../../src/database-adapter"; 9 | import { 10 | EventEmitter, 11 | EventListenerType, 12 | IEventEmitter, 13 | } from "../../src/emitter"; 14 | import { ITextOperation } from "../../src/text-operation"; 15 | import * as Utils from "../../src/utils"; 16 | import { clearMock, resetMock } from "./factory-utils"; 17 | 18 | Utils.validateFalse( 19 | jest == null, 20 | "This factories can only be imported in Test environment" 21 | ); 22 | 23 | type DatabaseAdapterConfigType = { 24 | userId: string; 25 | userName: string; 26 | userColor: string; 27 | }; 28 | 29 | let databaseRef: string | firebase.database.Reference; 30 | let user: DatabaseAdapterConfigType; 31 | 32 | const emitter: IEventEmitter = new EventEmitter(); 33 | 34 | export interface IDatabaseAdapterMock extends Partial { 35 | /** Trigger an event to lest event listeners */ 36 | trigger(event: DatabaseAdapterEvent, ...eventAttributes: any[]): void; 37 | /** Get current User object attached to the adapter */ 38 | getUser(): DatabaseAdapterConfigType; 39 | /** Get Database Reference attached to the adapter */ 40 | getDatabaseRef(): string | firebase.database.Reference; 41 | } 42 | 43 | const databaseAdapter: IDatabaseAdapterMock = Object.freeze({ 44 | isCurrentUser: jest.fn(() => false), 45 | isHistoryEmpty: jest.fn(() => true), 46 | on: jest.fn< 47 | void, 48 | [DatabaseAdapterEvent, EventListenerType] 49 | >((ev, handler) => { 50 | emitter.on(ev, handler); 51 | }), 52 | off: jest.fn< 53 | void, 54 | [DatabaseAdapterEvent, EventListenerType] 55 | >((ev, handler) => { 56 | emitter.off(ev, handler); 57 | }), 58 | registerCallbacks: jest.fn( 59 | (callbacks) => { 60 | Object.entries(callbacks).forEach(([event, listener]) => { 61 | emitter.on( 62 | event as DatabaseAdapterEvent, 63 | listener as EventListenerType 64 | ); 65 | }); 66 | } 67 | ), 68 | trigger: jest.fn((ev, ...attrs) => { 69 | emitter.trigger(ev, ...attrs); 70 | }), 71 | dispose: jest.fn(() => { 72 | emitter.dispose(); 73 | }), 74 | sendCursor: jest.fn(), 75 | sendOperation: jest.fn(), 76 | setUserColor: jest.fn((color) => { 77 | user.userColor = color; 78 | }), 79 | setUserId: jest.fn((userId) => { 80 | user.userId = userId; 81 | }), 82 | setUserName: jest.fn((name) => { 83 | user.userName = name; 84 | }), 85 | getUser: jest.fn(() => user), 86 | getDatabaseRef: jest.fn( 87 | () => databaseRef 88 | ), 89 | }); 90 | 91 | afterEach(() => { 92 | clearMock(databaseAdapter); 93 | }); 94 | 95 | afterAll(() => { 96 | emitter.dispose(); 97 | resetMock(databaseAdapter); 98 | }); 99 | 100 | /** 101 | * Returns a mock implementation of IDatabaseAdapter interface. 102 | * Useful for testing Editor Client, Firepad and related helper functions. 103 | */ 104 | export function getDatabaseAdapter( 105 | ref: string | firebase.database.Reference = ".root", 106 | userConfig: DatabaseAdapterConfigType = { 107 | userId: "user", 108 | userName: "User", 109 | userColor: "#ff00f3", 110 | } 111 | ): IDatabaseAdapterMock { 112 | databaseRef ||= ref; 113 | user ||= userConfig; 114 | return databaseAdapter; 115 | } 116 | -------------------------------------------------------------------------------- /test/factory/editor-adapter.factory.ts: -------------------------------------------------------------------------------- 1 | import { ICursor } from "../../src/cursor"; 2 | import { 3 | ClientIDType, 4 | EditorAdapterEvent, 5 | EditorEventCallbackType, 6 | IEditorAdapter, 7 | IEditorAdapterEvent, 8 | } from "../../src/editor-adapter"; 9 | import { 10 | EventEmitter, 11 | EventListenerType, 12 | IEventEmitter, 13 | } from "../../src/emitter"; 14 | import { ITextOperation } from "../../src/text-operation"; 15 | import * as Utils from "../../src/utils"; 16 | import { clearMock, resetMock } from "./factory-utils"; 17 | 18 | Utils.validateFalse( 19 | jest == null, 20 | "This factories can only be imported in Test environment" 21 | ); 22 | 23 | const emitter: IEventEmitter = new EventEmitter(); 24 | 25 | export interface IEditorAdapterMock extends Partial { 26 | /** Trigger an event to lest event listeners */ 27 | trigger(event: EditorAdapterEvent, ...eventAttributes: any[]): void; 28 | /** Disposes cursor inside editor Adapter */ 29 | disposeCursor(): void; 30 | /** Returns original editor instance */ 31 | getEditor(): any; 32 | } 33 | 34 | const disposeRemoteCursorStub = jest.fn(); 35 | let currentCursor: ICursor | null = null; 36 | let content: string = ""; 37 | let editorInstance: any; 38 | 39 | const editorAdapter: IEditorAdapterMock = Object.freeze({ 40 | on: jest.fn< 41 | void, 42 | [EditorAdapterEvent, EventListenerType] 43 | >((ev, handler) => { 44 | emitter.on(ev, handler); 45 | }), 46 | off: jest.fn< 47 | void, 48 | [EditorAdapterEvent, EventListenerType] 49 | >((ev, handler) => { 50 | emitter.off(ev, handler); 51 | }), 52 | registerCallbacks: jest.fn((callbacks) => { 53 | Object.entries(callbacks).forEach(([event, listener]) => { 54 | emitter.on( 55 | event as EditorAdapterEvent, 56 | listener as EventListenerType 57 | ); 58 | }); 59 | }), 60 | trigger: jest.fn((ev, ...attrs) => { 61 | emitter.trigger(ev, ...attrs); 62 | }), 63 | registerUndo: jest.fn((handler) => { 64 | emitter.on("undo", handler); 65 | }), 66 | registerRedo: jest.fn((handler) => { 67 | emitter.on("redo", handler); 68 | }), 69 | dispose: jest.fn(() => { 70 | emitter.dispose(); 71 | }), 72 | setInitiated: jest.fn(), 73 | getCursor: jest.fn(() => currentCursor), 74 | setCursor: jest.fn((cursor) => { 75 | currentCursor = cursor; 76 | }), 77 | setOtherCursor: jest.fn< 78 | Utils.IDisposable, 79 | [ClientIDType, ICursor, string, string | undefined] 80 | >(() => ({ 81 | dispose: disposeRemoteCursorStub, 82 | })), 83 | disposeCursor: disposeRemoteCursorStub, 84 | getEditor: jest.fn(() => editorInstance), 85 | getText: jest.fn(() => content), 86 | setText: jest.fn((text) => { 87 | content = text; 88 | }), 89 | applyOperation: jest.fn((operation) => { 90 | let index = 0; 91 | const contentArray = [...content]; 92 | const ops = operation.getOps(); 93 | 94 | for (const op of ops) { 95 | if (op.isRetain()) { 96 | index += op.chars; 97 | continue; 98 | } 99 | 100 | if (op.isDelete()) { 101 | contentArray.splice(index, op.chars); 102 | continue; 103 | } 104 | 105 | if (op.isInsert()) { 106 | contentArray.splice(index, 0, ...[...op.text]); 107 | index += op.text.length; 108 | } 109 | } 110 | 111 | content = contentArray.join(""); 112 | }), 113 | invertOperation: jest.fn((operation) => 114 | operation.invert(content) 115 | ), 116 | }); 117 | 118 | afterEach(() => { 119 | clearMock(editorAdapter); 120 | }); 121 | 122 | afterAll(() => { 123 | emitter.dispose(); 124 | resetMock(editorAdapter); 125 | }); 126 | 127 | /** 128 | * Returns a mock implementation of IEditorAdapter interface. 129 | * Useful for testing Editor Client, Firepad and related helper functions. 130 | */ 131 | export function getEditorAdapter(editor: any = null): IEditorAdapterMock { 132 | if (!editorInstance) { 133 | editorInstance = editor; 134 | } 135 | 136 | return editorAdapter; 137 | } 138 | -------------------------------------------------------------------------------- /test/factory/editor-client.factory.ts: -------------------------------------------------------------------------------- 1 | import { IDatabaseAdapter } from "../../src/database-adapter"; 2 | import { IEditorAdapter } from "../../src/editor-adapter"; 3 | import { EventEmitter, IEventEmitter } from "../../src/emitter"; 4 | import * as Utils from "../../src/utils"; 5 | 6 | Utils.validateFalse( 7 | jest == null, 8 | "This factories can only be imported in Test environment" 9 | ); 10 | 11 | const emitter: IEventEmitter = new EventEmitter(); 12 | const clearUndoRedoStackStub = jest.fn(); 13 | 14 | let databaseAdapter: IDatabaseAdapter; 15 | let editorAdapter: IEditorAdapter; 16 | 17 | export interface IEditorClientMock extends IEventEmitter { 18 | clearUndoRedoStack(): void; 19 | } 20 | 21 | const editorClient: IEditorClientMock = new Proxy(emitter, { 22 | get: function (target, prop, receiver) { 23 | if (prop === "clearUndoRedoStack") { 24 | return clearUndoRedoStackStub; 25 | } 26 | return Reflect.get(target, prop, receiver); 27 | }, 28 | }) as IEditorClientMock; 29 | 30 | afterEach(() => { 31 | clearUndoRedoStackStub.mockClear(); 32 | }); 33 | 34 | /** 35 | * Returns a mock implementation of IEditorClient interface. 36 | * Useful for testing Firepad and related helper functions. 37 | */ 38 | export function getEditorClient( 39 | _databaseAdapter?: IDatabaseAdapter, 40 | _editorAdapter?: IEditorAdapter 41 | ): IEditorClientMock { 42 | databaseAdapter ||= _databaseAdapter; 43 | editorAdapter ||= _editorAdapter; 44 | return editorClient; 45 | } 46 | -------------------------------------------------------------------------------- /test/factory/factory-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Resets all information stored in the mockFn.mock.calls and mockFn.mock.instances arrays. 3 | * 4 | * Often this is useful when you want to clean up a mock's usage data between two assertions. 5 | * @param factoryImpl - Factory Implementation with Mock Functions as methods. 6 | */ 7 | export function clearMock(factoryImpl: Object) { 8 | Object.values(factoryImpl).forEach((mockFn: jest.Mock) => mockFn.mockClear()); 9 | } 10 | 11 | /** 12 | * Resets all information stored in the mock, including any initial implementation and mock name given. 13 | * 14 | * This is useful when you want to completely restore a mock back to its initial state. 15 | * @param factoryImpl - Factory Implementation with Mock Functions as methods. 16 | */ 17 | export function resetMock(factoryImpl: Object) { 18 | Object.values(factoryImpl).forEach((mockFn: jest.Mock) => mockFn.mockReset()); 19 | } 20 | -------------------------------------------------------------------------------- /test/factory/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./database-adapter.factory"; 2 | export * from "./editor-adapter.factory"; 3 | export * from "./editor-client.factory"; 4 | export * from "./monaco-editor.factory"; 5 | -------------------------------------------------------------------------------- /test/factory/monaco-editor.factory.ts: -------------------------------------------------------------------------------- 1 | import { editor } from "monaco-editor"; 2 | import * as Utils from "../../src/utils"; 3 | import { clearMock, resetMock } from "./factory-utils"; 4 | 5 | Utils.validateFalse( 6 | jest == null, 7 | "This factories can only be imported in Test environment" 8 | ); 9 | 10 | let content: string = ""; 11 | 12 | const monacoEditor: Partial = Object.freeze({ 13 | getValue: jest.fn(() => content), 14 | }); 15 | 16 | afterEach(() => { 17 | clearMock(monacoEditor); 18 | }); 19 | 20 | afterAll(() => { 21 | resetMock(monacoEditor); 22 | }); 23 | 24 | /** 25 | * Returns a mock implementation of IStandaloneCodeEditor interface. 26 | * Useful for testing Monaco Adapter, Firepad.fromMonaco and related helper functions. 27 | */ 28 | export function getMonacoEditor(): editor.IStandaloneCodeEditor { 29 | return monacoEditor as editor.IStandaloneCodeEditor; 30 | } 31 | -------------------------------------------------------------------------------- /test/firepad-monaco.spec.ts: -------------------------------------------------------------------------------- 1 | import { editor } from "monaco-editor"; 2 | import { fromMonaco } from "../src/firepad-monaco"; 3 | import { getDatabaseAdapter, getEditorAdapter } from "./factory"; 4 | import { getMonacoEditor } from "./factory/monaco-editor.factory"; 5 | import firebase from "firebase/app"; 6 | 7 | jest.mock("../src/firebase-adapter", () => { 8 | const { getDatabaseAdapter } = require("./factory/database-adapter.factory"); 9 | class FirebaseAdapterMock { 10 | constructor( 11 | databaseRef: string | firebase.database.Reference, 12 | userId: number | string, 13 | userColor: string, 14 | userName: string 15 | ) { 16 | return getDatabaseAdapter(databaseRef, { userId, userColor, userName }); 17 | } 18 | } 19 | return { 20 | __esModule: true, 21 | FirebaseAdapter: FirebaseAdapterMock, 22 | }; 23 | }); 24 | 25 | jest.mock("../src/monaco-adapter", () => { 26 | const { getEditorAdapter } = require("./factory/editor-adapter.factory"); 27 | class MonacoAdapterMock { 28 | constructor(monacoInstance: editor.IStandaloneCodeEditor) { 29 | return getEditorAdapter(monacoInstance); 30 | } 31 | } 32 | return { 33 | __esModule: true, 34 | MonacoAdapter: MonacoAdapterMock, 35 | }; 36 | }); 37 | 38 | describe("fromMonaco", () => { 39 | let databaseRef: string; 40 | let monacoEditor: editor.IStandaloneCodeEditor; 41 | 42 | beforeAll(() => { 43 | databaseRef = ".root/firepad"; 44 | monacoEditor = getMonacoEditor(); 45 | }); 46 | 47 | it("should allow passing additional configuration object", () => { 48 | const userId = "user"; 49 | fromMonaco(databaseRef, monacoEditor, { userId }); 50 | expect(getDatabaseAdapter().getUser().userId).toEqual(userId); 51 | }); 52 | 53 | it("should create database adapter from Firebase reference", () => { 54 | fromMonaco(databaseRef, monacoEditor); 55 | expect(getDatabaseAdapter().getDatabaseRef()).toEqual(databaseRef); 56 | }); 57 | 58 | it("should create editor adapter from Monaco instance", () => { 59 | fromMonaco(databaseRef, monacoEditor); 60 | expect(getEditorAdapter().getEditor()).toEqual(monacoEditor); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/firepad.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DatabaseAdapterEvent, 3 | DatabaseAdapterStateType, 4 | IDatabaseAdapter, 5 | } from "../src/database-adapter"; 6 | import { EditorAdapterStateType, IEditorAdapter } from "../src/editor-adapter"; 7 | import { EditorClientEvent } from "../src/editor-client"; 8 | import { Firepad, FirepadEvent, IFirepad } from "../src/firepad"; 9 | import { 10 | getDatabaseAdapter, 11 | getEditorAdapter, 12 | getEditorClient, 13 | IDatabaseAdapterMock, 14 | IEditorAdapterMock, 15 | } from "./factory"; 16 | 17 | jest.mock("../src/editor-client", () => { 18 | const { EditorClientEvent } = jest.requireActual("../src/editor-client"); 19 | const { getEditorClient } = require("./factory/editor-client.factory"); 20 | class EditorClientMock { 21 | constructor( 22 | databaseAdapter: IDatabaseAdapter, 23 | editorAdapter: IEditorAdapter 24 | ) { 25 | return getEditorClient(databaseAdapter, editorAdapter); 26 | } 27 | } 28 | return { 29 | __esModule: true, 30 | EditorClientEvent, 31 | EditorClient: EditorClientMock, 32 | }; 33 | }); 34 | 35 | describe("Firepad", () => { 36 | let databaseAdapter: IDatabaseAdapterMock; 37 | let editorAdapter: IEditorAdapterMock; 38 | let firepad: IFirepad; 39 | 40 | beforeAll(() => { 41 | databaseAdapter = getDatabaseAdapter(); 42 | editorAdapter = getEditorAdapter(); 43 | 44 | firepad = new Firepad( 45 | databaseAdapter as IDatabaseAdapter, 46 | editorAdapter as IEditorAdapter, 47 | databaseAdapter.getUser() 48 | ); 49 | }); 50 | 51 | afterAll(() => { 52 | firepad.dispose(); 53 | }); 54 | 55 | describe("#isHistoryEmpty", () => { 56 | it("should throw error if called before Firepad is Ready", () => { 57 | const fn = () => firepad.isHistoryEmpty(); 58 | expect(fn).toThrowError(); 59 | }); 60 | 61 | it("should return true if no activity done yet by any user", () => { 62 | databaseAdapter.trigger(DatabaseAdapterEvent.Ready); 63 | firepad.isHistoryEmpty(); 64 | expect(databaseAdapter.isHistoryEmpty).toHaveBeenCalled(); 65 | }); 66 | }); 67 | 68 | describe("#getConfiguration", () => { 69 | it("should return User Id of current Firepad", () => { 70 | databaseAdapter.trigger(DatabaseAdapterEvent.Ready); 71 | expect(firepad.getConfiguration("userId")).toEqual( 72 | databaseAdapter.getUser().userId 73 | ); 74 | }); 75 | 76 | it("should return User Name of current Firepad", () => { 77 | databaseAdapter.trigger(DatabaseAdapterEvent.Ready); 78 | expect(firepad.getConfiguration("userName")).toEqual( 79 | databaseAdapter.getUser().userName 80 | ); 81 | }); 82 | 83 | it("should return User Color of current Firepad", () => { 84 | databaseAdapter.trigger(DatabaseAdapterEvent.Ready); 85 | expect(firepad.getConfiguration("userColor")).toEqual( 86 | databaseAdapter.getUser().userColor 87 | ); 88 | }); 89 | }); 90 | 91 | describe("#setUserId", () => { 92 | it("should set User Id", () => { 93 | const userId = "user123"; 94 | firepad.setUserId(userId); 95 | expect(databaseAdapter.getUser().userId).toEqual(userId); 96 | }); 97 | }); 98 | 99 | describe("#setUserColor", () => { 100 | it("should set User Color", () => { 101 | const userColor = "#ff0023"; 102 | firepad.setUserColor(userColor); 103 | expect(databaseAdapter.getUser().userColor).toEqual(userColor); 104 | }); 105 | }); 106 | 107 | describe("#setUserName", () => { 108 | it("should set User Name", () => { 109 | const userName = "Adam"; 110 | firepad.setUserName(userName); 111 | expect(databaseAdapter.getUser().userName).toEqual(userName); 112 | }); 113 | }); 114 | 115 | describe("#getText", () => { 116 | it("should return current content of the Editor adapter", () => { 117 | expect(firepad.getText()).toEqual(editorAdapter.getText()); 118 | }); 119 | }); 120 | 121 | describe("#setText", () => { 122 | it("should set content to the Editor adapter", () => { 123 | const content = "Hello World"; 124 | firepad.setText(content); 125 | expect(editorAdapter.getText()).toEqual(content); 126 | }); 127 | }); 128 | 129 | describe("#clearUndoRedoStack", () => { 130 | it("clear undo and redo stack from editor client", () => { 131 | firepad.clearUndoRedoStack(); 132 | expect(getEditorClient().clearUndoRedoStack).toHaveBeenCalled(); 133 | }); 134 | }); 135 | 136 | describe("#on", () => { 137 | let onSyncListenerStub: jest.Mock; 138 | let onUndoListenerStub: jest.Mock; 139 | let onRedoListenerStub: jest.Mock; 140 | let onErrorListenerStub: jest.Mock< 141 | void, 142 | [Error, string, DatabaseAdapterStateType | EditorAdapterStateType] 143 | >; 144 | 145 | beforeAll(() => { 146 | onSyncListenerStub = jest.fn(); 147 | onUndoListenerStub = jest.fn(); 148 | onRedoListenerStub = jest.fn(); 149 | onErrorListenerStub = jest.fn(); 150 | jest.useFakeTimers(); 151 | }); 152 | 153 | afterEach(() => { 154 | onSyncListenerStub.mockClear(); 155 | onUndoListenerStub.mockClear(); 156 | onRedoListenerStub.mockClear(); 157 | onErrorListenerStub.mockClear(); 158 | }); 159 | 160 | afterAll(() => { 161 | jest.clearAllTimers(); 162 | jest.useRealTimers(); 163 | }); 164 | 165 | it("should listen to Synced event", () => { 166 | firepad.on(FirepadEvent.Synced, onSyncListenerStub); 167 | getEditorClient().trigger(EditorClientEvent.Synced, true); 168 | jest.runAllTimers(); 169 | expect(onSyncListenerStub).toHaveBeenCalledWith(true); 170 | }); 171 | 172 | it("should listen to Undo event", () => { 173 | firepad.on(FirepadEvent.Undo, onUndoListenerStub); 174 | getEditorClient().trigger(EditorClientEvent.Undo, "Retain 120"); 175 | jest.runAllTimers(); 176 | expect(onUndoListenerStub).toHaveBeenCalledWith("Retain 120"); 177 | }); 178 | 179 | it("should listen to Redo event", () => { 180 | firepad.on(FirepadEvent.Redo, onRedoListenerStub); 181 | getEditorClient().trigger(EditorClientEvent.Redo, "Retain 120"); 182 | jest.runAllTimers(); 183 | expect(onRedoListenerStub).toHaveBeenCalledWith("Retain 120"); 184 | }); 185 | 186 | it("should listen to Error event", () => { 187 | const error = new Error("Something went Wrong"); 188 | const state = { 189 | document: "", 190 | }; 191 | 192 | firepad.on(FirepadEvent.Error, onErrorListenerStub); 193 | getEditorClient().trigger( 194 | EditorClientEvent.Error, 195 | error, 196 | "Retain 120", 197 | state 198 | ); 199 | jest.runAllTimers(); 200 | expect(onErrorListenerStub).toHaveBeenCalledWith( 201 | error, 202 | "Retain 120", 203 | state 204 | ); 205 | }); 206 | }); 207 | 208 | describe("#off", () => { 209 | let onSyncListenerStub: jest.Mock; 210 | 211 | beforeAll(() => { 212 | onSyncListenerStub = jest.fn(); 213 | jest.useFakeTimers(); 214 | }); 215 | 216 | afterEach(() => { 217 | onSyncListenerStub.mockClear(); 218 | }); 219 | 220 | afterAll(() => { 221 | jest.clearAllTimers(); 222 | jest.useRealTimers(); 223 | }); 224 | 225 | it("should remove listener", () => { 226 | firepad.on(FirepadEvent.Synced, onSyncListenerStub); 227 | firepad.off(FirepadEvent.Synced, onSyncListenerStub); 228 | getEditorClient().trigger(EditorClientEvent.Synced, true); 229 | jest.runAllTimers(); 230 | expect(onSyncListenerStub).not.toHaveBeenCalled(); 231 | }); 232 | }); 233 | 234 | describe("#dispose", () => { 235 | it("should throw error if Firepad already disposed", () => { 236 | firepad.dispose(); 237 | const fn = () => firepad.clearUndoRedoStack(); 238 | expect(fn).toThrowError(); 239 | }); 240 | }); 241 | }); 242 | -------------------------------------------------------------------------------- /test/operation-meta.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cursor } from "../src/cursor"; 2 | import { OperationMeta } from "../src/operation-meta"; 3 | import { TextOperation } from "../src/text-operation"; 4 | 5 | describe("Operation Metadata", () => { 6 | describe("#clone", () => { 7 | it("should create deep clone of Operation Metadata object", () => { 8 | const cursorBefore = new Cursor(1, 4); 9 | const cursorAfter = new Cursor(2, 8); 10 | const operationMeta = new OperationMeta(cursorBefore, cursorAfter); 11 | expect(operationMeta.clone()).toEqual(operationMeta); 12 | }); 13 | }); 14 | 15 | describe("#invert", () => { 16 | it("should swap properties of Operation Metadata object", () => { 17 | const cursorBefore = new Cursor(1, 4); 18 | const cursorAfter = new Cursor(2, 8); 19 | const operationMeta = new OperationMeta(cursorBefore, cursorAfter); 20 | const invertMeta = new OperationMeta(cursorAfter, cursorBefore); 21 | expect(operationMeta.invert()).toEqual(invertMeta); 22 | }); 23 | }); 24 | 25 | describe("#compose", () => { 26 | it("should merge properties of two Operation Metadata object", () => { 27 | const cursorBefore = new Cursor(1, 4); 28 | const cursorAfter = new Cursor(2, 8); 29 | const operationMeta = new OperationMeta(cursorBefore, cursorAfter); 30 | const cursorBeforeOther = new Cursor(3, 3); 31 | const cursorAfterOther = new Cursor(4, 9); 32 | const operationMetaOther = new OperationMeta( 33 | cursorBeforeOther, 34 | cursorAfterOther 35 | ); 36 | const composedMeta = new OperationMeta(cursorBefore, cursorAfterOther); 37 | expect(operationMeta.compose(operationMetaOther)).toEqual(composedMeta); 38 | }); 39 | }); 40 | 41 | describe("#transform", () => { 42 | it("should transform properties of Operation Metadata object", () => { 43 | const cursorBefore = new Cursor(1, 4); 44 | const cursorAfter = new Cursor(2, 8); 45 | const operationMeta = new OperationMeta(cursorBefore, cursorAfter); 46 | const operation = new TextOperation().insert("Hello World", null); // 11 letters inserted. 47 | const cursorBeforeTransformed = new Cursor(12, 15); 48 | const cursorAfterTransformed = new Cursor(13, 19); 49 | const transformedMeta = new OperationMeta( 50 | cursorBeforeTransformed, 51 | cursorAfterTransformed 52 | ); 53 | expect(operationMeta.transform(operation)).toEqual(transformedMeta); 54 | }); 55 | 56 | it("should not throw error for null values", () => { 57 | const operationMeta = new OperationMeta(null, null); 58 | const operation = new TextOperation().retain(12, null); 59 | const fn = () => operationMeta.transform(operation); 60 | expect(fn).not.toThrowError(); 61 | }); 62 | }); 63 | 64 | describe("#getCursor", () => { 65 | it("should return final position of the Cursor", () => { 66 | const cursorBefore = new Cursor(1, 4); 67 | const cursorAfter = new Cursor(2, 8); 68 | const operationMeta = new OperationMeta(cursorBefore, cursorAfter); 69 | expect(operationMeta.getCursor()).toEqual(cursorAfter); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /test/remote-client.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cursor } from "../src/cursor"; 2 | import { UserIDType } from "../src/database-adapter"; 3 | import { IEditorAdapter } from "../src/editor-adapter"; 4 | import { IRemoteClient, RemoteClient } from "../src/remote-client"; 5 | import { getEditorAdapter, IEditorAdapterMock } from "./factory"; 6 | 7 | describe("Remote Client", () => { 8 | let clientId: UserIDType; 9 | let remoteClient: IRemoteClient; 10 | let editorAdapter: IEditorAdapterMock; 11 | 12 | beforeAll(() => { 13 | clientId = Math.round(Math.random() * 100); 14 | editorAdapter = getEditorAdapter(); 15 | 16 | remoteClient = new RemoteClient(clientId, editorAdapter as IEditorAdapter); 17 | }); 18 | 19 | describe("#setColor", () => { 20 | it("should set cursor/selection color for remote user", () => { 21 | const fn = () => remoteClient.setColor("#fff"); 22 | expect(fn).not.toThrowError(); 23 | }); 24 | }); 25 | 26 | describe("#setUserName", () => { 27 | it("should set name/short-name for remote user", () => { 28 | const fn = () => remoteClient.setUserName("Robin"); 29 | expect(fn).not.toThrowError(); 30 | }); 31 | }); 32 | 33 | describe("#updateCursor", () => { 34 | it("should update cursor/selection position for remote user", () => { 35 | const userCursor = new Cursor(5, 8); 36 | remoteClient.updateCursor(userCursor); 37 | expect(editorAdapter.setOtherCursor).toHaveBeenCalledWith( 38 | clientId, 39 | userCursor, 40 | "#fff", 41 | "Robin" 42 | ); 43 | }); 44 | }); 45 | 46 | describe("#removeCursor", () => { 47 | it("should remove cursor/selection for remote user", () => { 48 | remoteClient.removeCursor(); 49 | expect(editorAdapter.disposeCursor).toHaveBeenCalled(); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/text-op.spec.ts: -------------------------------------------------------------------------------- 1 | import { TextOp, TextOptType } from "../src/text-op"; 2 | 3 | describe("Text Op", () => { 4 | describe("#isInsert", () => { 5 | it("should return true for Insert operation", () => { 6 | const op = new TextOp(TextOptType.Insert, "Hello World", null); 7 | expect(op.isInsert()).toEqual(true); 8 | }); 9 | }); 10 | 11 | describe("#isDelete", () => { 12 | it("should return true for Delete operation", () => { 13 | const op = new TextOp(TextOptType.Delete, 5, null); 14 | expect(op.isDelete()).toEqual(true); 15 | }); 16 | }); 17 | 18 | describe("#isRetain", () => { 19 | it("should return true Retain operation", () => { 20 | const op = new TextOp(TextOptType.Retain, 5, null); 21 | expect(op.isRetain()).toEqual(true); 22 | }); 23 | }); 24 | 25 | describe("#equals", () => { 26 | it("should return true if two ops are same", () => { 27 | const op1 = new TextOp(TextOptType.Insert, "Hello Me", { attr: true }); 28 | const op2 = new TextOp(TextOptType.Insert, "Hello Me", { attr: true }); 29 | expect(op1.equals(op2)).toEqual(true); 30 | }); 31 | 32 | it("should return false if two ops are different", () => { 33 | const op1 = new TextOp(TextOptType.Insert, "Hello Me", { attr: true }); 34 | const op2 = new TextOp(TextOptType.Delete, 11, null); 35 | expect(op1.equals(op2)).toEqual(false); 36 | }); 37 | }); 38 | 39 | describe("#attributesEqual", () => { 40 | it("should return true if two ops have same attributes", () => { 41 | const op1 = new TextOp(TextOptType.Insert, "Hello Me", { attr: true }); 42 | const op2 = new TextOp(TextOptType.Retain, 12, { attr: true }); 43 | expect(op1.attributesEqual(op2.attributes)).toEqual(true); 44 | }); 45 | 46 | it("should return true if two ops have null attribute", () => { 47 | const op1 = new TextOp(TextOptType.Delete, 20, null); 48 | const op2 = new TextOp(TextOptType.Delete, 12, null); 49 | expect(op1.attributesEqual(op2.attributes)).toEqual(true); 50 | }); 51 | 52 | it("should return false if two ops have different attributes", () => { 53 | const op1 = new TextOp(TextOptType.Insert, "Hello World", null); 54 | const op2 = new TextOp(TextOptType.Insert, "Hello World", { 55 | attr: false, 56 | }); 57 | expect(op1.attributesEqual(op2.attributes)).toEqual(false); 58 | }); 59 | 60 | it("should return false if two ops have different attribute values", () => { 61 | const op1 = new TextOp(TextOptType.Insert, "Hello World", { attr: true }); 62 | const op2 = new TextOp(TextOptType.Insert, "Hello World", { 63 | attr: false, 64 | }); 65 | expect(op1.attributesEqual(op2.attributes)).toEqual(false); 66 | }); 67 | }); 68 | 69 | describe("#hasEmptyAttributes", () => { 70 | it("should return true if the op has null attribute", () => { 71 | const op = new TextOp(TextOptType.Delete, 11, null); 72 | expect(op.hasEmptyAttributes()).toEqual(true); 73 | }); 74 | 75 | it("should return true if the op has empty attributes", () => { 76 | const op = new TextOp(TextOptType.Insert, "Hello Me", {}); 77 | expect(op.hasEmptyAttributes()).toEqual(true); 78 | }); 79 | 80 | it("should return false if the op has non-empty attributes", () => { 81 | const op = new TextOp(TextOptType.Insert, "Hello World", { attr: true }); 82 | expect(op.hasEmptyAttributes()).toEqual(false); 83 | }); 84 | }); 85 | 86 | describe("#toString", () => { 87 | it("should pretty print Insert operation", () => { 88 | const text = "Hello World"; 89 | const op = new TextOp(TextOptType.Insert, text, null); 90 | expect(op.toString()).toEqual(`Insert "${text}"`); 91 | }); 92 | 93 | it("should pretty print Delete operation", () => { 94 | const chars = 52; 95 | const op = new TextOp(TextOptType.Delete, chars, null); 96 | expect(op.toString()).toEqual(`Delete ${chars}`); 97 | }); 98 | 99 | it("should pretty print Retain operation", () => { 100 | const chars = 34; 101 | const op = new TextOp(TextOptType.Retain, chars, null); 102 | expect(op.toString()).toEqual(`Retain ${chars}`); 103 | }); 104 | }); 105 | 106 | describe("#valueOf", () => { 107 | it("should return text for Insert operation", () => { 108 | const text = "Hello World"; 109 | const op = new TextOp(TextOptType.Insert, text, null); 110 | expect(op.valueOf()).toEqual(text); 111 | }); 112 | 113 | it("should return negative character count for Delete operation", () => { 114 | const chars = 52; 115 | const op = new TextOp(TextOptType.Delete, chars, null); 116 | expect(op.valueOf()).toEqual(-chars); 117 | }); 118 | 119 | it("should return character count for Retain operation", () => { 120 | const chars = 34; 121 | const op = new TextOp(TextOptType.Retain, chars, null); 122 | expect(op.valueOf()).toEqual(chars); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /test/undo-manager.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cursor } from "../src/cursor"; 2 | import { OperationMeta } from "../src/operation-meta"; 3 | import { TextOperation } from "../src/text-operation"; 4 | import { IUndoManager, UndoManager } from "../src/undo-manager"; 5 | import { IWrappedOperation, WrappedOperation } from "../src/wrapped-operation"; 6 | 7 | describe("Undo Manager", () => { 8 | let undoManager: IUndoManager; 9 | let wrappedOperation: IWrappedOperation; 10 | 11 | beforeAll(() => { 12 | const operation = new TextOperation().retain(15, null); 13 | const operationMeta = new OperationMeta(new Cursor(0, 0), new Cursor(4, 9)); 14 | wrappedOperation = new WrappedOperation(operation, operationMeta); 15 | }); 16 | 17 | beforeEach(() => { 18 | undoManager = new UndoManager(); 19 | }); 20 | 21 | afterEach(() => { 22 | undoManager.dispose(); 23 | undoManager = null; 24 | }); 25 | 26 | describe("#dispose", () => { 27 | it("should cleanup Undo stack", () => { 28 | undoManager.add(wrappedOperation); 29 | undoManager.dispose(); 30 | expect(undoManager.canUndo()).toEqual(false); 31 | }); 32 | 33 | it("should cleanup Redo stack", () => { 34 | undoManager.add(wrappedOperation); 35 | undoManager.dispose(); 36 | expect(undoManager.canRedo()).toEqual(false); 37 | }); 38 | }); 39 | 40 | describe("#add", () => { 41 | it("should add operation to Undo stack in normal state", () => { 42 | undoManager.add(wrappedOperation); 43 | expect(undoManager.canUndo()).toEqual(true); 44 | }); 45 | 46 | it("should add operation to Redo stack in undoing state", () => { 47 | undoManager.add(wrappedOperation); 48 | undoManager.performUndo(() => { 49 | undoManager.add(wrappedOperation.invert("")); 50 | }); 51 | expect(undoManager.canRedo()).toEqual(true); 52 | }); 53 | 54 | it("should add operation to Undo stack in redoing state", () => { 55 | undoManager.add(wrappedOperation); 56 | undoManager.performUndo(() => { 57 | undoManager.add(wrappedOperation.invert("")); 58 | }); 59 | undoManager.performRedo(() => { 60 | undoManager.add(wrappedOperation); 61 | }); 62 | expect(undoManager.canUndo()).toEqual(true); 63 | }); 64 | 65 | it("should compose with last operation if exists and compose set to true", () => { 66 | const nextOperation = wrappedOperation.clone(); 67 | undoManager = new UndoManager(1); 68 | undoManager.add(wrappedOperation); 69 | undoManager.add(nextOperation, true); 70 | undoManager.performUndo(() => { 71 | /** Empty Callback */ 72 | }); 73 | expect(undoManager.canUndo()).toEqual(false); 74 | }); 75 | 76 | it("should not add more operations than the limit given", () => { 77 | undoManager = new UndoManager(1); 78 | undoManager.add(wrappedOperation); 79 | undoManager.add(wrappedOperation.invert("Test")); 80 | undoManager.performUndo(() => { 81 | /** Empty Callback */ 82 | }); 83 | expect(undoManager.canUndo()).toEqual(false); 84 | }); 85 | 86 | it("should throw error if the limit is set to zero", () => { 87 | const fn = () => new UndoManager(0); 88 | expect(fn).toThrowError(); 89 | }); 90 | }); 91 | 92 | describe("#last", () => { 93 | it("should return last operation in Undo stack", () => { 94 | undoManager.add(wrappedOperation); 95 | expect(undoManager.last()).toEqual(wrappedOperation); 96 | }); 97 | }); 98 | 99 | describe("#transform", () => { 100 | it("should transform Undo/Redo stack to incoming operation", () => { 101 | undoManager.add(wrappedOperation); 102 | const operation = new TextOperation() 103 | .retain(15, null) 104 | .insert("Hello", null); 105 | undoManager.transform(operation); 106 | expect(undoManager.last()).not.toEqual(wrappedOperation); 107 | }); 108 | }); 109 | 110 | describe("#isUndoing", () => { 111 | it("should return true if the manager is undoing an operation", (done) => { 112 | undoManager.add(wrappedOperation); 113 | undoManager.performUndo(() => { 114 | expect(undoManager.isUndoing()).toEqual(true); 115 | done(); 116 | }); 117 | }); 118 | }); 119 | 120 | describe("#isRedoing", () => { 121 | it("should return true if the manager is redoing an operation", (done) => { 122 | undoManager.add(wrappedOperation); 123 | undoManager.performUndo(() => { 124 | undoManager.add(wrappedOperation.invert("")); 125 | }); 126 | undoManager.performRedo(() => { 127 | expect(undoManager.isRedoing()).toEqual(true); 128 | done(); 129 | }); 130 | }); 131 | }); 132 | }); 133 | --------------------------------------------------------------------------------