├── CHANGELOG.md ├── screenshot.png ├── font ├── firepad.eot ├── firepad.ttf ├── firepad.woff ├── Read Me.txt ├── firepad.svg └── firepad.dev.svg ├── .gitignore ├── lib ├── span.js ├── text.js ├── line.js ├── constants.js ├── entity.js ├── text-pieces-to-inserts.js ├── cursor.js ├── formatting.js ├── line-formatting.js ├── wrapped-operation.js ├── text-op.js ├── undo-manager.js ├── entity-manager.js ├── utils.js ├── headless.js ├── client.js ├── rich-text-toolbar.js ├── editor-client.js ├── ace-adapter.coffee ├── serialize-html.js └── parse-html.js ├── examples ├── index.html ├── security │ ├── README.md │ ├── secret-url.json │ └── validate-auth.json ├── README.md ├── firepad.rb ├── firepad-userlist.css ├── monaco.html ├── code.html ├── ace.html ├── richtext-simple.html ├── userlist.html ├── richtext.html ├── hammer.html └── firepad-userlist.js ├── test ├── specs │ ├── cursor.spec.js │ ├── monaco-ops.spec.js │ ├── helpers.js │ ├── wrapped-operation.spec.js │ ├── undomanager.spec.js │ ├── client.spec.js │ ├── parse-html.spec.js │ └── integration.spec.js ├── firepad-debug.js ├── index.html └── karma.conf.js ├── .github ├── workflows │ └── node.yml ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE.md └── CONTRIBUTING.md ├── bower.json ├── tools ├── update-examples-version.sh └── release.sh ├── package.json ├── LICENSE ├── Gruntfile.js └── README.md /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FirebaseExtended/firepad/HEAD/screenshot.png -------------------------------------------------------------------------------- /font/firepad.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FirebaseExtended/firepad/HEAD/font/firepad.eot -------------------------------------------------------------------------------- /font/firepad.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FirebaseExtended/firepad/HEAD/font/firepad.ttf -------------------------------------------------------------------------------- /font/firepad.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FirebaseExtended/firepad/HEAD/font/firepad.woff -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | build/ 4 | bower_components/ 5 | *.sw? 6 | .DS_Store 7 | test/coverage/ 8 | node_modules 9 | 10 | # generated 11 | lib/ace-adapter.js 12 | dist/firepad.zip 13 | dist/ 14 | 15 | *.log 16 | _site/ 17 | .firebase/ 18 | -------------------------------------------------------------------------------- /lib/span.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | firepad.Span = (function () { 3 | function Span(pos, length) { 4 | this.pos = pos; 5 | this.length = length; 6 | } 7 | 8 | Span.prototype.end = function() { 9 | return this.pos + this.length; 10 | }; 11 | 12 | return Span; 13 | }()); 14 | -------------------------------------------------------------------------------- /font/Read Me.txt: -------------------------------------------------------------------------------- 1 | To modify your generated font, use the *dev.svg* file, located in the *fonts* folder in this package. You can import this dev.svg file to the IcoMoon app. All the tags (class names) and the Unicode points of your glyphs are saved in this file. 2 | 3 | See the documentation for more info on how to use this package: http://icomoon.io/#docs/font-face -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | Code (CodeMirror editor)
2 | Code (ACE editor)
3 | Code (Monaco editor)
4 | Rich Text (Simple)
5 | Rich Text
6 | User List
7 | Hammer Time!
8 | -------------------------------------------------------------------------------- /lib/text.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | 3 | /** 4 | * Object to represent Formatted text. 5 | * 6 | * @type {Function} 7 | */ 8 | firepad.Text = (function() { 9 | function Text(text, formatting) { 10 | // Allow calling without new. 11 | if (!(this instanceof Text)) { return new Text(text, formatting); } 12 | 13 | this.text = text; 14 | this.formatting = formatting || firepad.Formatting(); 15 | } 16 | 17 | return Text; 18 | })(); 19 | -------------------------------------------------------------------------------- /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 | Example demonstrating how to secure Firepad by using secret URLs (you need the secret URL to be able to 5 | find / modify the Firebase data. 6 | 7 | # validate-auth.json 8 | Example demonstrating how to require that users be authenticated to read and write to the Firepad. Also ensures that 9 | all edits correctly include the authenticated user's id (i.e. prevent users from spoofing each other). -------------------------------------------------------------------------------- /lib/line.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | 3 | /** 4 | * Object to represent Formatted line. 5 | * 6 | * @type {Function} 7 | */ 8 | firepad.Line = (function() { 9 | function Line(textPieces, formatting) { 10 | // Allow calling without new. 11 | if (!(this instanceof Line)) { return new Line(textPieces, formatting); } 12 | 13 | if(Object.prototype.toString.call(textPieces) !== '[object Array]') { 14 | if (typeof textPieces === 'undefined') { 15 | textPieces = []; 16 | } else { 17 | textPieces = [textPieces]; 18 | } 19 | } 20 | 21 | this.textPieces = textPieces; 22 | this.formatting = formatting || firepad.LineFormatting(); 23 | } 24 | 25 | return Line; 26 | })(); 27 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | ## Examples 2 | 3 | You can run the examples by simply opening any of the following files in your browser: 4 | 5 | * [`code.html`](./code.html) - Code-editing using CodeMirror. 6 | * [`ace.html`](./ace.html) - Code-editing using ACE. 7 | * [`richtext-simple.html`](./richtext-simple.html) - Simple rich-text editing. 8 | * [`richtext.html`](./richtext.html) - More advanced rich-text editing. 9 | * [`userlist.html`](./userlist.html) - Rich-text editing with a list of users showing who's 10 | currently present. 11 | 12 | ## Security Rules 13 | 14 | Example Realtime Database Security Rules to protect your Firepad data can be found in the 15 | [`security/`](./security) directory. 16 | 17 | ## Integrations 18 | 19 | * `firepad.rb` - A Ruby script for loading the contents of a Firepad from your server-side Ruby code. 20 | -------------------------------------------------------------------------------- /lib/constants.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | 3 | firepad.AttributeConstants = { 4 | BOLD: 'b', 5 | ITALIC: 'i', 6 | UNDERLINE: 'u', 7 | STRIKE: 's', 8 | FONT: 'f', 9 | FONT_SIZE: 'fs', 10 | COLOR: 'c', 11 | BACKGROUND_COLOR: 'bc', 12 | ENTITY_SENTINEL: 'ent', 13 | 14 | // Line Attributes 15 | LINE_SENTINEL: 'l', 16 | LINE_INDENT: 'li', 17 | LINE_ALIGN: 'la', 18 | LIST_TYPE: 'lt' 19 | }; 20 | 21 | firepad.sentinelConstants = { 22 | // A special character we insert at the beginning of lines so we can attach attributes to it to represent 23 | // "line attributes." E000 is from the unicode "private use" range. 24 | LINE_SENTINEL_CHARACTER: '\uE000', 25 | 26 | // A special character used to represent any "entity" inserted into the document (e.g. an image). 27 | ENTITY_SENTINEL_CHARACTER: '\uE001' 28 | }; 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/specs/cursor.spec.js: -------------------------------------------------------------------------------- 1 | describe('Cursor Tests', function() { 2 | var Cursor = firepad.Cursor; 3 | var TextOperation = firepad.TextOperation; 4 | 5 | it('FromJSON', function() { 6 | var cursor = Cursor.fromJSON({ position: 3, selectionEnd: 5 }); 7 | expect(cursor instanceof Cursor).toBeTruthy(); 8 | expect(cursor.position).toBe(3); 9 | expect(cursor.selectionEnd).toBe(5); 10 | }); 11 | 12 | it('Transform', function() { 13 | var cursor = new Cursor(3, 7); 14 | expect(cursor 15 | .transform(new TextOperation().retain(3).insert('lorem')['delete'](2).retain(42)) 16 | .equals(new Cursor(8, 10))).toBeTruthy(); 17 | expect(cursor 18 | .transform(new TextOperation()['delete'](45)) 19 | .equals(new Cursor(0, 0))).toBeTruthy(); 20 | }); 21 | 22 | it('Compose', function() { 23 | var a = new Cursor(3, 7); 24 | var b = new Cursor(4, 4); 25 | expect(a.compose(b)).toBe(b); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/workflows/node.yml: -------------------------------------------------------------------------------- 1 | 2 | name: CI Tests 3 | 4 | on: 5 | pull_request: 6 | branches-ignore: 'gh-pages' 7 | push: 8 | branches-ignore: 'gh-pages' 9 | 10 | env: 11 | CI: true 12 | 13 | jobs: 14 | unit: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | node-version: 19 | - 10.x 20 | steps: 21 | - uses: actions/checkout@v1 22 | - uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: Cache npm 27 | uses: actions/cache@v1 28 | with: 29 | path: ~/.npm 30 | key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} 31 | 32 | - name: Install dependencies 33 | run: npm install 34 | 35 | - name: Run tests 36 | run: npm run travis 37 | 38 | - name: Coveralls 39 | uses: coverallsapp/github-action@master 40 | with: 41 | github-token: ${{ secrets.GITHUB_TOKEN }} 42 | path-to-lcov: ./test/coverage/lcov.info 43 | -------------------------------------------------------------------------------- /test/firepad-debug.js: -------------------------------------------------------------------------------- 1 | function load(script) { 2 | document.write(''); 3 | } 4 | 5 | // TODO: auto-generate this. 6 | load('../lib/utils.js'); 7 | load('../lib/span.js'); 8 | load('../lib/text-op.js'); 9 | load('../lib/text-operation.js'); 10 | load('../lib/annotation-list.js'); 11 | load('../lib/cursor.js'); 12 | load('../lib/firebase-adapter.js'); 13 | load('../lib/rich-text-toolbar.js'); 14 | load('../lib/wrapped-operation.js'); 15 | load('../lib/undo-manager.js'); 16 | load('../lib/client.js'); 17 | load('../lib/editor-client.js'); 18 | load('../lib/ace-adapter.js'); 19 | load('../lib/constants.js'); 20 | load('../lib/entity-manager.js'); 21 | load('../lib/entity.js'); 22 | load('../lib/rich-text-codemirror.js'); 23 | load('../lib/rich-text-codemirror-adapter.js'); 24 | load('../lib/formatting.js'); 25 | load('../lib/text.js'); 26 | load('../lib/line-formatting.js'); 27 | load('../lib/line.js'); 28 | load('../lib/parse-html.js'); 29 | load('../lib/serialize-html.js'); 30 | load('../lib/text-pieces-to-inserts.js'); 31 | load('../lib/headless.js'); 32 | load('../lib/firepad.js'); 33 | -------------------------------------------------------------------------------- /lib/entity.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | 3 | /** 4 | * Object to represent an Entity. 5 | */ 6 | firepad.Entity = (function() { 7 | var ATTR = firepad.AttributeConstants; 8 | var SENTINEL = ATTR.ENTITY_SENTINEL; 9 | var PREFIX = SENTINEL + '_'; 10 | 11 | function Entity(type, info) { 12 | // Allow calling without new. 13 | if (!(this instanceof Entity)) { return new Entity(type, info); } 14 | 15 | this.type = type; 16 | this.info = info || { }; 17 | } 18 | 19 | Entity.prototype.toAttributes = function() { 20 | var attrs = { }; 21 | attrs[SENTINEL] = this.type; 22 | 23 | for(var attr in this.info) { 24 | attrs[PREFIX + attr] = this.info[attr]; 25 | } 26 | 27 | return attrs; 28 | }; 29 | 30 | Entity.fromAttributes = function(attributes) { 31 | var type = attributes[SENTINEL]; 32 | var info = { }; 33 | for(var attr in attributes) { 34 | if (attr.indexOf(PREFIX) === 0) { 35 | info[attr.substr(PREFIX.length)] = attributes[attr]; 36 | } 37 | } 38 | 39 | return new Entity(type, info); 40 | }; 41 | 42 | return Entity; 43 | })(); 44 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firepad", 3 | "description": "Collaborative text editing powered by Firebase", 4 | "version": "1.5.3", 5 | "authors": [ 6 | "Firebase (https://firebase.google.com/)", 7 | "Michael Lehenbauer " 8 | ], 9 | "homepage": "http://www.firepad.io", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/firebase/firepad.git" 13 | }, 14 | "license": "MIT", 15 | "keywords": [ 16 | "text", 17 | "word", 18 | "editor", 19 | "firebase", 20 | "realtime", 21 | "collaborative" 22 | ], 23 | "main": [ 24 | "dist/firepad.min.js", 25 | "dist/firepad.css", 26 | "dist/firepad.eot" 27 | ], 28 | "ignore": [ 29 | "**/.*", 30 | "lib", 31 | "font", 32 | "test", 33 | "examples", 34 | "node_modules", 35 | "bower_components", 36 | "Gruntfile.js", 37 | "package.json", 38 | "changelog.txt" 39 | ], 40 | "dependencies": { 41 | "jsdom": "^12.2.0", 42 | "firebase": "5.x.x" 43 | }, 44 | "devDependencies": { 45 | "jasmine-core": "3.x.x", 46 | "codemirror": "5.x.x" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tools/update-examples-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # update-examples-version.sh - Updates all examples/ to the latest version. 3 | 4 | set -o nounset 5 | set -o errexit 6 | 7 | # Go to repo root. 8 | script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 9 | cd "${script_dir}/.."; 10 | 11 | exit_on_error() { 12 | echo $1 13 | exit 1 14 | } 15 | 16 | git diff-index --quiet HEAD || exit_on_error "Modified files present; please commit or revert them." 17 | git pull &> /dev/null 18 | 19 | version=$(npm view . version) 20 | version="v${version}" 21 | 22 | echo "New Version: ${version}" 23 | 24 | firepad_url="https://firepad.io/releases/${version}/firepad.min.js" 25 | curl --output /dev/null --silent --head --fail ${firepad_url} || exit_on_error "Latest version not published to firepad.io? ${firepad_url} not found." 26 | 27 | cd examples/ 28 | sed -i.bak """s#firepad.io/releases/[^/]*/#firepad.io/releases/${version}/#g""" *.html 29 | rm *.bak 30 | 31 | echo 32 | echo "Examples updated. Pushing changes to GitHub." 33 | git commit -am "[firepad-release] Bumped examples to v${version}" 34 | git push origin master 35 | 36 | echo 37 | echo "Done." 38 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 | ### Description 25 | 26 | 28 | 29 | ### Code sample 30 | 31 | 32 | -------------------------------------------------------------------------------- /lib/text-pieces-to-inserts.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | 3 | /** 4 | * Helper to turn pieces of text into insertable operations 5 | */ 6 | firepad.textPiecesToInserts = function(atNewLine, textPieces) { 7 | var inserts = []; 8 | 9 | function insert(string, attributes) { 10 | if (string instanceof firepad.Text) { 11 | attributes = string.formatting.attributes; 12 | string = string.text; 13 | } 14 | 15 | inserts.push({string: string, attributes: attributes}); 16 | atNewLine = string[string.length-1] === '\n'; 17 | } 18 | 19 | function insertLine(line, withNewline) { 20 | // HACK: We should probably force a newline if there isn't one already. But due to 21 | // the way this is used for inserting HTML, we end up inserting a "line" in the middle 22 | // of text, in which case we don't want to actually insert a newline. 23 | if (atNewLine) { 24 | insert(firepad.sentinelConstants.LINE_SENTINEL_CHARACTER, line.formatting.attributes); 25 | } 26 | 27 | for(var i = 0; i < line.textPieces.length; i++) { 28 | insert(line.textPieces[i]); 29 | } 30 | 31 | if (withNewline) insert('\n'); 32 | } 33 | 34 | for(var i = 0; i < textPieces.length; i++) { 35 | if (textPieces[i] instanceof firepad.Line) { 36 | insertLine(textPieces[i], i 2 | 3 | 4 | Firepad Tests 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /lib/cursor.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | firepad.Cursor = (function () { 3 | 'use strict'; 4 | 5 | // A cursor has a `position` and a `selectionEnd`. Both are zero-based indexes 6 | // into the document. When nothing is selected, `selectionEnd` is equal to 7 | // `position`. When there is a selection, `position` is always the side of the 8 | // selection that would move if you pressed an arrow key. 9 | function Cursor (position, selectionEnd) { 10 | this.position = position; 11 | this.selectionEnd = selectionEnd; 12 | } 13 | 14 | Cursor.fromJSON = function (obj) { 15 | return new Cursor(obj.position, obj.selectionEnd); 16 | }; 17 | 18 | Cursor.prototype.equals = function (other) { 19 | return this.position === other.position && 20 | this.selectionEnd === other.selectionEnd; 21 | }; 22 | 23 | // Return the more current cursor information. 24 | Cursor.prototype.compose = function (other) { 25 | return other; 26 | }; 27 | 28 | // Update the cursor with respect to an operation. 29 | Cursor.prototype.transform = function (other) { 30 | function transformIndex (index) { 31 | var newIndex = index; 32 | var ops = other.ops; 33 | for (var i = 0, l = other.ops.length; i < l; i++) { 34 | if (ops[i].isRetain()) { 35 | index -= ops[i].chars; 36 | } else if (ops[i].isInsert()) { 37 | newIndex += ops[i].text.length; 38 | } else { 39 | newIndex -= Math.min(index, ops[i].chars); 40 | index -= ops[i].chars; 41 | } 42 | if (index < 0) { break; } 43 | } 44 | return newIndex; 45 | } 46 | 47 | var newPosition = transformIndex(this.position); 48 | if (this.position === this.selectionEnd) { 49 | return new Cursor(newPosition, newPosition); 50 | } 51 | return new Cursor(newPosition, transformIndex(this.selectionEnd)); 52 | }; 53 | 54 | return Cursor; 55 | 56 | }()); 57 | 58 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 30 | 31 | 32 | ### Version info 33 | 34 | 36 | 37 | **Firebase:** 38 | 39 | **Firepad:** 40 | 41 | **Ace:** 42 | 43 | **CodeMirror:** 44 | 45 | **Other (e.g. Node, browser, operating system) (if applicable):** 46 | 47 | ### Test case 48 | 49 | 51 | 52 | 53 | ### Steps to reproduce 54 | 55 | 56 | 57 | 58 | ### Expected behavior 59 | 60 | 61 | 62 | 63 | ### Actual behavior 64 | 65 | 66 | -------------------------------------------------------------------------------- /test/specs/monaco-ops.spec.js: -------------------------------------------------------------------------------- 1 | /** Monaco Adapter Unit Tests */ 2 | describe('Monaco Operations Test', function () { 3 | /** Editor Content */ 4 | var editorContent = 5 | `module Conway { 6 | 7 | export class Cell { 8 | public row: number; 9 | public col: number; 10 | public live: boolean; 11 | 12 | constructor(row: number, col: number, live: boolean) { 13 | this.row = row; 14 | this.col = col; 15 | this.live = live 16 | } 17 | } 18 | } 19 | `; 20 | 21 | /** Editor Changes */ 22 | var operations = [ 23 | { rangeLength: 0, text: '/* ', rangeOffset: 21, forceMoveMarkers: false }, 24 | { rangeLength: 0, text: ' */', rangeOffset: 299, forceMoveMarkers: false } 25 | ]; 26 | 27 | /** Expected Text Operations */ 28 | var textOperations = [ 29 | new firepad.TextOperation().retain(21).insert('/* ').retain(281), 30 | new firepad.TextOperation().retain(299).insert(' */').retain(6) 31 | ]; 32 | 33 | it('should convert Monaco Editor changes to Text Operation', function () { 34 | var MonacoAdapter = firepad.MonacoAdapter; 35 | var operationFromMonacoChange = MonacoAdapter.prototype.operationFromMonacoChanges; 36 | 37 | let offset = 0; 38 | operations.forEach((operation, index) => { 39 | var pair = operationFromMonacoChange.call(null, operation, editorContent, offset); 40 | 41 | /** Base Length of First Operation must be Target Length of Second Operation */ 42 | expect(pair[1].targetLength).toEqual(pair[0].baseLength); 43 | 44 | /** Base Length of Second Operation must be Target Length of First Operation */ 45 | expect(pair[0].targetLength).toEqual(pair[1].baseLength); 46 | 47 | /** Correct Operations Returned */ 48 | expect(pair[0]).toEqual(textOperations[index]); 49 | 50 | /** Update Offset */ 51 | offset += pair[0].targetLength - pair[0].baseLength; 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firepad", 3 | "description": "Collaborative text editing powered by Firebase", 4 | "version": "1.5.11", 5 | "author": "Firebase (https://firebase.google.com/)", 6 | "contributors": [ 7 | "Michael Lehenbauer " 8 | ], 9 | "homepage": "http://www.firepad.io/", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/firebase/firepad.git" 13 | }, 14 | "browser": { 15 | "jsdom": false 16 | }, 17 | "bugs": { 18 | "url": "https://github.com/firebase/firepad/issues" 19 | }, 20 | "licenses": [ 21 | { 22 | "type": "MIT", 23 | "url": "http://firebase.mit-license.org/" 24 | } 25 | ], 26 | "keywords": [ 27 | "text", 28 | "word", 29 | "editor", 30 | "firebase", 31 | "realtime", 32 | "collaborative" 33 | ], 34 | "main": "dist/firepad.min.js", 35 | "files": [ 36 | "dist/**", 37 | "LICENSE", 38 | "README.md", 39 | "package.json" 40 | ], 41 | "dependencies": { 42 | "firebase": "^7.13.2", 43 | "jsdom": "^16.2.2" 44 | }, 45 | "devDependencies": { 46 | "@babel/core": "^7.9.0", 47 | "@babel/preset-env": "^7.9.0", 48 | "codemirror": "^5.52.2", 49 | "coveralls": "^3.0.11", 50 | "grunt": "^1.1.0", 51 | "grunt-cli": "^1.3.2", 52 | "grunt-coffeelint": "0.0.16", 53 | "grunt-contrib-coffee": "^2.1.0", 54 | "grunt-contrib-concat": "^1.0.1", 55 | "grunt-contrib-copy": "^1.0.0", 56 | "grunt-contrib-uglify-es": "github:gruntjs/grunt-contrib-uglify#harmony", 57 | "grunt-contrib-watch": "^1.1.0", 58 | "grunt-karma": "^3.0.2", 59 | "jasmine-core": "^3.5.0", 60 | "karma": "^4.4.1", 61 | "karma-chrome-launcher": "^3.1.0", 62 | "karma-coverage": "^2.0.1", 63 | "karma-failed-reporter": "0.0.3", 64 | "karma-jasmine": "^3.1.1", 65 | "karma-spec-reporter": "0.0.32", 66 | "monaco-editor": "^0.20.0" 67 | }, 68 | "scripts": { 69 | "test": "grunt test", 70 | "travis": "grunt", 71 | "dev": "grunt watch", 72 | "build": "grunt build" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/formatting.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | 3 | /** 4 | * Immutable object to represent text formatting. Formatting can be modified by chaining method calls. 5 | * 6 | * @constructor 7 | * @type {Function} 8 | */ 9 | firepad.Formatting = (function() { 10 | var ATTR = firepad.AttributeConstants; 11 | 12 | function Formatting(attributes) { 13 | // Allow calling without new. 14 | if (!(this instanceof Formatting)) { return new Formatting(attributes); } 15 | 16 | this.attributes = attributes || { }; 17 | } 18 | 19 | Formatting.prototype.cloneWithNewAttribute_ = function(attribute, value) { 20 | var attributes = { }; 21 | 22 | // Copy existing. 23 | for(var attr in this.attributes) { 24 | attributes[attr] = this.attributes[attr]; 25 | } 26 | 27 | // Add new one. 28 | if (value === false) { 29 | delete attributes[attribute]; 30 | } else { 31 | attributes[attribute] = value; 32 | } 33 | 34 | return new Formatting(attributes); 35 | }; 36 | 37 | Formatting.prototype.bold = function(val) { 38 | return this.cloneWithNewAttribute_(ATTR.BOLD, val); 39 | }; 40 | 41 | Formatting.prototype.italic = function(val) { 42 | return this.cloneWithNewAttribute_(ATTR.ITALIC, val); 43 | }; 44 | 45 | Formatting.prototype.underline = function(val) { 46 | return this.cloneWithNewAttribute_(ATTR.UNDERLINE, val); 47 | }; 48 | 49 | Formatting.prototype.strike = function(val) { 50 | return this.cloneWithNewAttribute_(ATTR.STRIKE, val); 51 | }; 52 | 53 | Formatting.prototype.font = function(font) { 54 | return this.cloneWithNewAttribute_(ATTR.FONT, font); 55 | }; 56 | 57 | Formatting.prototype.fontSize = function(size) { 58 | return this.cloneWithNewAttribute_(ATTR.FONT_SIZE, size); 59 | }; 60 | 61 | Formatting.prototype.color = function(color) { 62 | return this.cloneWithNewAttribute_(ATTR.COLOR, color); 63 | }; 64 | 65 | Formatting.prototype.backgroundColor = function(color) { 66 | return this.cloneWithNewAttribute_(ATTR.BACKGROUND_COLOR, color); 67 | }; 68 | 69 | return Formatting; 70 | })(); 71 | -------------------------------------------------------------------------------- /lib/line-formatting.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | 3 | /** 4 | * Immutable object to represent line formatting. Formatting can be modified by chaining method calls. 5 | * 6 | * @constructor 7 | * @type {Function} 8 | */ 9 | firepad.LineFormatting = (function() { 10 | var ATTR = firepad.AttributeConstants; 11 | 12 | function LineFormatting(attributes) { 13 | // Allow calling without new. 14 | if (!(this instanceof LineFormatting)) { return new LineFormatting(attributes); } 15 | 16 | this.attributes = attributes || { }; 17 | this.attributes[ATTR.LINE_SENTINEL] = true; 18 | } 19 | 20 | LineFormatting.LIST_TYPE = { 21 | NONE: false, 22 | ORDERED: 'o', 23 | UNORDERED: 'u', 24 | TODO: 't', 25 | TODOCHECKED: 'tc' 26 | }; 27 | 28 | LineFormatting.prototype.cloneWithNewAttribute_ = function(attribute, value) { 29 | var attributes = { }; 30 | 31 | // Copy existing. 32 | for(var attr in this.attributes) { 33 | attributes[attr] = this.attributes[attr]; 34 | } 35 | 36 | // Add new one. 37 | if (value === false) { 38 | delete attributes[attribute]; 39 | } else { 40 | attributes[attribute] = value; 41 | } 42 | 43 | return new LineFormatting(attributes); 44 | }; 45 | 46 | LineFormatting.prototype.indent = function(indent) { 47 | return this.cloneWithNewAttribute_(ATTR.LINE_INDENT, indent); 48 | }; 49 | 50 | LineFormatting.prototype.align = function(align) { 51 | return this.cloneWithNewAttribute_(ATTR.LINE_ALIGN, align); 52 | }; 53 | 54 | LineFormatting.prototype.listItem = function(val) { 55 | firepad.utils.assert(val === false || val === 'u' || val === 'o' || val === 't' || val === 'tc'); 56 | return this.cloneWithNewAttribute_(ATTR.LIST_TYPE, val); 57 | }; 58 | 59 | LineFormatting.prototype.getIndent = function() { 60 | return this.attributes[ATTR.LINE_INDENT] || 0; 61 | }; 62 | 63 | LineFormatting.prototype.getAlign = function() { 64 | return this.attributes[ATTR.LINE_ALIGN] || 0; 65 | }; 66 | 67 | LineFormatting.prototype.getListItem = function() { 68 | return this.attributes[ATTR.LIST_TYPE] || false; 69 | }; 70 | 71 | return LineFormatting; 72 | })(); 73 | -------------------------------------------------------------------------------- /examples/firepad.rb: -------------------------------------------------------------------------------- 1 | # This module requires that a global FIREBASE object is initialized, from the gem 2 | # `firebase` (https://rubygems.org/gems/firebase). If you don't want to do that, it's 3 | # pretty easy to tweak this code to take the FIREBASE object as a param, or even 4 | # to hit the REST endpoints yourself using whatever HTTP client you prefer. 5 | # 6 | # To load the final text value of a pad created through the official JavaScript 7 | # Firepad client, call `Firepad.load "PADNAME"`. This will check for a snapshot, 8 | # then mutate a string in place to produce the final output. We also account for 9 | # JavaScript's shitty string representation (JS expresses the poop emoji as two chars). 10 | 11 | module Firepad 12 | def self.load pad_path 13 | if checkpoint = FIREBASE.get("#{pad_path}/checkpoint").body 14 | doc = checkpoint['o'].first 15 | doc = '' unless doc.is_a? String 16 | history = FIREBASE.get("#{pad_path}/history", orderBy: '"$key"', startAt: "\"#{checkpoint['id']}\"").body.sort 17 | history.shift 18 | else 19 | doc = '' 20 | history = FIREBASE.get("#{pad_path}/history").body 21 | return nil unless history 22 | history = history.sort 23 | end 24 | 25 | doc.pad_surrogate_pairs! 26 | history.each do |_, ops| 27 | idx = 0 28 | ops['o'].each do |op| 29 | if op.is_a? Fixnum 30 | if op > 0 31 | # retain 32 | idx += op 33 | else 34 | # delete 35 | doc.slice! idx, -op 36 | end 37 | else 38 | # insert 39 | op.pad_surrogate_pairs! 40 | doc.insert idx, op 41 | idx += op.length 42 | end 43 | end 44 | raise "The operation didn't operate on the whole string." if idx != doc.length 45 | end 46 | 47 | doc.delete! "\0".freeze 48 | doc 49 | end 50 | end 51 | 52 | class String 53 | def pad_surrogate_pairs! 54 | offset = 0 55 | self.each_codepoint.with_index do |codepoint, idx| 56 | if codepoint >= 0x10000 && codepoint <= 0x10FFFF 57 | self.insert idx + offset, "\0".freeze 58 | offset += 1 59 | end 60 | end 61 | self 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | frameworks: ["jasmine"], 4 | browsers: ["ChromeHeadlessNoSandbox"], 5 | 6 | // See: 7 | // https://docs.travis-ci.com/user/chrome 8 | customLaunchers: { 9 | ChromeHeadlessNoSandbox: { 10 | base: 'ChromeHeadless', 11 | flags: ['--no-sandbox'] 12 | } 13 | }, 14 | 15 | preprocessors: { 16 | "../lib/*.js": "coverage" 17 | }, 18 | 19 | plugins: [ 20 | "karma-chrome-launcher", 21 | "karma-jasmine", 22 | "karma-coverage", 23 | "karma-spec-reporter", 24 | "karma-failed-reporter" 25 | ], 26 | 27 | reporters: ["spec", "failed", "coverage"], 28 | coverageReporter: { 29 | reporters: [ 30 | { 31 | type: "lcovonly", 32 | dir: "coverage", 33 | subdir: "." 34 | }, 35 | { 36 | type: "text-summary" 37 | } 38 | ] 39 | }, 40 | 41 | browserNoActivityTimeout: 60000, 42 | 43 | files: [ 44 | "../node_modules/codemirror/lib/codemirror.js", 45 | "../node_modules/firebase/firebase.js", 46 | "./vendor/ace-1.2.5.js", 47 | 48 | "../lib/utils.js", 49 | "../lib/span.js", 50 | "../lib/text-op.js", 51 | "../lib/text-operation.js", 52 | "../lib/annotation-list.js", 53 | "../lib/cursor.js", 54 | "../lib/firebase-adapter.js", 55 | "../lib/rich-text-toolbar.js", 56 | "../lib/wrapped-operation.js", 57 | "../lib/undo-manager.js", 58 | "../lib/client.js", 59 | "../lib/editor-client.js", 60 | "../lib/ace-adapter.js", 61 | "../lib/monaco-adapter.js", 62 | "../lib/constants.js", 63 | "../lib/entity-manager.js", 64 | "../lib/entity.js", 65 | "../lib/rich-text-codemirror.js", 66 | "../lib/rich-text-codemirror-adapter.js", 67 | "../lib/formatting.js", 68 | "../lib/text.js", 69 | "../lib/line-formatting.js", 70 | "../lib/line.js", 71 | "../lib/parse-html.js", 72 | "../lib/serialize-html.js", 73 | "../lib/text-pieces-to-inserts.js", 74 | "../lib/headless.js", 75 | "../lib/firepad.js", 76 | 77 | "./specs/helpers.js", 78 | "./specs/*.spec.js" 79 | ] 80 | }); 81 | }; 82 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Firebase, https://firebase.google.com/ 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | 21 | -- 22 | 23 | ot.js 24 | 25 | Copyright © 2012-2015 Tim Baumann, http://timbaumann.info 26 | 27 | Permission is hereby granted, free of charge, to any person obtaining a copy 28 | of this software and associated documentation files (the “Software”), to deal 29 | in the Software without restriction, including without limitation the rights 30 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 31 | copies of the Software, and to permit persons to whom the Software is 32 | furnished to do so, subject to the following conditions: 33 | 34 | The above copyright notice and this permission notice shall be included in 35 | all copies or substantial portions of the Software. 36 | 37 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 38 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 39 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 40 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 41 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 42 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 43 | THE SOFTWARE. 44 | -------------------------------------------------------------------------------- /lib/wrapped-operation.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | firepad.WrappedOperation = (function (global) { 3 | 'use strict'; 4 | 5 | // A WrappedOperation contains an operation and corresponing metadata. 6 | function WrappedOperation (operation, meta) { 7 | this.wrapped = operation; 8 | this.meta = meta; 9 | } 10 | 11 | WrappedOperation.prototype.apply = function () { 12 | return this.wrapped.apply.apply(this.wrapped, arguments); 13 | }; 14 | 15 | WrappedOperation.prototype.invert = function () { 16 | var meta = this.meta; 17 | return new WrappedOperation( 18 | this.wrapped.invert.apply(this.wrapped, arguments), 19 | meta && typeof meta === 'object' && typeof meta.invert === 'function' ? 20 | meta.invert.apply(meta, arguments) : meta 21 | ); 22 | }; 23 | 24 | // Copy all properties from source to target. 25 | function copy (source, target) { 26 | for (var key in source) { 27 | if (source.hasOwnProperty(key)) { 28 | target[key] = source[key]; 29 | } 30 | } 31 | } 32 | 33 | function composeMeta (a, b) { 34 | if (a && typeof a === 'object') { 35 | if (typeof a.compose === 'function') { return a.compose(b); } 36 | var meta = {}; 37 | copy(a, meta); 38 | copy(b, meta); 39 | return meta; 40 | } 41 | return b; 42 | } 43 | 44 | WrappedOperation.prototype.compose = function (other) { 45 | return new WrappedOperation( 46 | this.wrapped.compose(other.wrapped), 47 | composeMeta(this.meta, other.meta) 48 | ); 49 | }; 50 | 51 | function transformMeta (meta, operation) { 52 | if (meta && typeof meta === 'object') { 53 | if (typeof meta.transform === 'function') { 54 | return meta.transform(operation); 55 | } 56 | } 57 | return meta; 58 | } 59 | 60 | WrappedOperation.transform = function (a, b) { 61 | var pair = a.wrapped.transform(b.wrapped); 62 | return [ 63 | new WrappedOperation(pair[0], transformMeta(a.meta, b.wrapped)), 64 | new WrappedOperation(pair[1], transformMeta(b.meta, a.wrapped)) 65 | ]; 66 | }; 67 | 68 | // convenience method to write transform(a, b) as a.transform(b) 69 | WrappedOperation.prototype.transform = function(other) { 70 | return WrappedOperation.transform(this, other); 71 | }; 72 | 73 | return WrappedOperation; 74 | 75 | }()); 76 | -------------------------------------------------------------------------------- /lib/text-op.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | 3 | firepad.TextOp = (function() { 4 | var utils = firepad.utils; 5 | 6 | // Operation are essentially lists of ops. There are three types of ops: 7 | // 8 | // * Retain ops: Advance the cursor position by a given number of characters. 9 | // Represented by positive ints. 10 | // * Insert ops: Insert a given string at the current cursor position. 11 | // Represented by strings. 12 | // * Delete ops: Delete the next n characters. Represented by negative ints. 13 | function TextOp(type) { 14 | this.type = type; 15 | this.chars = null; 16 | this.text = null; 17 | this.attributes = null; 18 | 19 | if (type === 'insert') { 20 | this.text = arguments[1]; 21 | utils.assert(typeof this.text === 'string'); 22 | this.attributes = arguments[2] || { }; 23 | utils.assert (typeof this.attributes === 'object'); 24 | } else if (type === 'delete') { 25 | this.chars = arguments[1]; 26 | utils.assert(typeof this.chars === 'number'); 27 | } else if (type === 'retain') { 28 | this.chars = arguments[1]; 29 | utils.assert(typeof this.chars === 'number'); 30 | this.attributes = arguments[2] || { }; 31 | utils.assert (typeof this.attributes === 'object'); 32 | } 33 | } 34 | 35 | TextOp.prototype.isInsert = function() { return this.type === 'insert'; }; 36 | TextOp.prototype.isDelete = function() { return this.type === 'delete'; }; 37 | TextOp.prototype.isRetain = function() { return this.type === 'retain'; }; 38 | 39 | TextOp.prototype.equals = function(other) { 40 | return (this.type === other.type && 41 | this.text === other.text && 42 | this.chars === other.chars && 43 | this.attributesEqual(other.attributes)); 44 | }; 45 | 46 | TextOp.prototype.attributesEqual = function(otherAttributes) { 47 | for (var attr in this.attributes) { 48 | if (this.attributes[attr] !== otherAttributes[attr]) { return false; } 49 | } 50 | 51 | for (attr in otherAttributes) { 52 | if (this.attributes[attr] !== otherAttributes[attr]) { return false; } 53 | } 54 | 55 | return true; 56 | }; 57 | 58 | TextOp.prototype.hasEmptyAttributes = function() { 59 | var empty = true; 60 | for (var attr in this.attributes) { 61 | empty = false; 62 | break; 63 | } 64 | 65 | return empty; 66 | }; 67 | 68 | return TextOp; 69 | })(); 70 | -------------------------------------------------------------------------------- /examples/firepad-userlist.css: -------------------------------------------------------------------------------- 1 | .firepad-userlist { 2 | /* default height */ 3 | height: 400px; 4 | min-width: 175px; 5 | 6 | background: #ebebeb; /* Old browsers */ 7 | background: -moz-linear-gradient(left, #ebebeb 0%, #eaeaea 93%, #d9d9d9 100%); /* FF3.6+ */ 8 | background: -webkit-gradient(linear, left top, right top, color-stop(0%,#ebebeb), color-stop(93%,#eaeaea), color-stop(100%,#d9d9d9)); /* Chrome,Safari4+ */ 9 | background: -webkit-linear-gradient(left, #ebebeb 0%,#eaeaea 93%,#d9d9d9 100%); /* Chrome10+,Safari5.1+ */ 10 | background: -o-linear-gradient(left, #ebebeb 0%,#eaeaea 93%,#d9d9d9 100%); /* Opera 11.10+ */ 11 | background: -ms-linear-gradient(left, #ebebeb 0%,#eaeaea 93%,#d9d9d9 100%); /* IE10+ */ 12 | background: linear-gradient(to right, #ebebeb 0%,#eaeaea 93%,#d9d9d9 100%); /* W3C */ 13 | color: #404040; 14 | } 15 | 16 | .firepad-userlist { 17 | text-align: left; 18 | font-family: 'Helvetica Neue', sans-serif; 19 | line-height: normal; 20 | } 21 | 22 | .firepad-userlist-heading { 23 | margin: 20px 15px 0; 24 | font-size: 12px; 25 | font-weight: bold; 26 | border-bottom: 2px solid #c9c9c9; 27 | } 28 | 29 | .firepad-userlist-users { 30 | position: absolute; 31 | left: 15px; 32 | right: 15px; 33 | top: 38px; 34 | bottom: 10px; 35 | overflow-y: auto; 36 | overflow-x: hidden; 37 | } 38 | 39 | .firepad-userlist-user { 40 | position: relative; 41 | margin: 3px 0; 42 | height: 32px; 43 | border-bottom: 1px solid #c9c9c9; 44 | padding-bottom: 5px; 45 | } 46 | 47 | .firepad-userlist-color-indicator { 48 | display: inline-block; 49 | width: 32px; 50 | height: 32px; 51 | border: 1px solid #ccc; 52 | -webkit-border-radius: 4px; 53 | -moz-border-radius: 4px; 54 | border-radius: 4px; 55 | } 56 | 57 | input.firepad-userlist-name-input { 58 | position: absolute; 59 | left: 38px; 60 | top: 0; 61 | width: 105px; 62 | height: 20px; 63 | border: 0; 64 | border-bottom: 1px solid #d6d6d6; 65 | -webkit-border-radius: 0; 66 | -moz-border-radius: 0; 67 | border-radius: 0; 68 | font-size: 14px; 69 | line-height: 14px; 70 | padding: 1px; 71 | } 72 | 73 | .firepad-userlist-name-hint { 74 | position: absolute; 75 | left: 38px; 76 | top: 23px; 77 | width: 300px; /* I'd rather it clip than wrap. */ 78 | font-size: 9px; 79 | line-height: 11px; 80 | } 81 | 82 | .firepad-userlist-name { 83 | position: absolute; 84 | top: 2px; 85 | left: 38px; 86 | width: 95px; 87 | font-size: 13px; 88 | } 89 | -------------------------------------------------------------------------------- /examples/monaco.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 25 | 26 | 27 | 28 |
29 | 30 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /test/specs/helpers.js: -------------------------------------------------------------------------------- 1 | // Initialize the Firebase SDK 2 | var config = { 3 | apiKey: "", 4 | authDomain: "firepad-gh-tests.firebaseapp.com", 5 | databaseURL: "https://firepad-gh-tests.firebaseio.com", 6 | }; 7 | firebase.initializeApp(config); 8 | 9 | var helpers = (function() { 10 | var TextOperation = firepad.TextOperation; 11 | 12 | var helpers = { }; 13 | helpers.randomInt = function(n) { 14 | return Math.floor(Math.random() * n); 15 | }; 16 | 17 | helpers.randomString = function(n) { 18 | var str = ''; 19 | while (n--) { 20 | if (Math.random() < 0.15) { 21 | str += '\n'; 22 | } else { 23 | var chr = helpers.randomInt(26) + 97; 24 | str += String.fromCharCode(chr); 25 | } 26 | } 27 | return str; 28 | }; 29 | 30 | var attrNames = ['a', 'b', 'c', 'd', 'e', 'f', 'g']; 31 | var attrValues = [-4, 0, 10, 50, '0', '10', 'a', 'b', 'c', true, false]; 32 | 33 | helpers.randomAttributes = function(allowFalse) { 34 | var attributes = { }; 35 | var count = helpers.randomInt(3); 36 | for(var i = 0; i < count; i++) { 37 | var name = attrNames[helpers.randomInt(attrNames.length)]; 38 | var value = attrValues[helpers.randomInt(attrValues.length - (allowFalse ? 0 : 1))]; 39 | attributes[name] = value; 40 | } 41 | 42 | return attributes; 43 | }; 44 | 45 | helpers.randomAttributesArray = function(n) { 46 | var attributes = Array(n); 47 | for(var i = 0; i < n; i++) { 48 | attributes[i] = helpers.randomAttributes(); 49 | } 50 | return attributes; 51 | }; 52 | 53 | helpers.randomOperation = function(str, useAttributes) { 54 | var operation = new TextOperation(); 55 | var left; 56 | while (true) { 57 | left = str.length - operation.baseLength; 58 | if (left === 0) { break; } 59 | var r = Math.random(); 60 | var l = 1 + helpers.randomInt(Math.min(left - 1, 20)); 61 | if (r < 0.2) { 62 | operation.insert(helpers.randomString(l), (useAttributes ? helpers.randomAttributes() : { })); 63 | } else if (r < 0.4) { 64 | operation['delete'](l); 65 | } else { 66 | operation.retain(l, (useAttributes ? helpers.randomAttributes(/*allowFalse=*/true) : { })); 67 | } 68 | } 69 | if (Math.random() < 0.3) { 70 | operation.insert(1 + helpers.randomString(10)); 71 | } 72 | return operation; 73 | }; 74 | 75 | // A random test generates random data to check some invariants. To increase 76 | // confidence in a random test, it is run repeatedly. 77 | helpers.randomTest = function(n, func) { 78 | return function () { 79 | while (n--) { 80 | func(); 81 | } 82 | }; 83 | }; 84 | 85 | function randomElement (arr) { 86 | return arr[helpers.randomInt(arr.length)]; 87 | } 88 | 89 | return helpers; 90 | })(); 91 | -------------------------------------------------------------------------------- /examples/code.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 27 | 28 | 29 | 30 |
31 | 32 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /examples/ace.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 27 | 28 | 29 | 30 |
31 | 32 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /examples/richtext-simple.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 28 | 29 | 30 | 31 |
32 | 33 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /test/specs/wrapped-operation.spec.js: -------------------------------------------------------------------------------- 1 | describe('WrappedOperation', function() { 2 | var WrappedOperation = firepad.WrappedOperation; 3 | var TextOperation = firepad.TextOperation; 4 | var Cursor = firepad.Cursor; 5 | var h = helpers; 6 | var n = 20; 7 | 8 | it('Apply', helpers.randomTest(n, function() { 9 | var str = h.randomString(50); 10 | var operation = h.randomOperation(str); 11 | var wrapped = new WrappedOperation(operation, { lorem: 42 }); 12 | expect(wrapped.meta.lorem).toBe(42); 13 | expect(wrapped.apply(str)).toBe(operation.apply(str)); 14 | })); 15 | 16 | it('Invert', helpers.randomTest(n, function() { 17 | var str = h.randomString(50); 18 | var operation = h.randomOperation(str); 19 | var payload = { lorem: 'ipsum' }; 20 | var wrapped = new WrappedOperation(operation, payload); 21 | var wrappedInverted = wrapped.invert(str); 22 | expect(wrappedInverted.meta).toBe(payload); 23 | expect(str).toBe(wrappedInverted.apply(operation.apply(str))); 24 | })); 25 | 26 | it('InvertMethod', function() { 27 | var str = h.randomString(50); 28 | var operation = h.randomOperation(str); 29 | var meta = { invert: function (doc) { return doc; } }; 30 | var wrapped = new WrappedOperation(operation, meta); 31 | expect(wrapped.invert(str).meta).toBe(str); 32 | }); 33 | 34 | it('Compose', helpers.randomTest(n, function() { 35 | var str = h.randomString(50); 36 | var a = new WrappedOperation(h.randomOperation(str), { a: 1, b: 2 }); 37 | var strN = a.apply(str); 38 | var b = new WrappedOperation(h.randomOperation(strN), { a: 3, c: 4 }); 39 | var ab = a.compose(b); 40 | expect(ab.meta.a).toBe(3); 41 | expect(ab.meta.b).toBe(2); 42 | expect(ab.meta.c).toBe(4); 43 | expect(ab.apply(str)).toBe(b.apply(strN)); 44 | })); 45 | 46 | it('ComposeMethod', function() { 47 | var meta = { 48 | timesComposed: 0, 49 | compose: function (other) { 50 | return { 51 | timesComposed: this.timesComposed + other.timesComposed + 1, 52 | compose: meta.compose 53 | }; 54 | } 55 | }; 56 | var str = h.randomString(50); 57 | var a = new WrappedOperation(h.randomOperation(str), meta); 58 | var strN = a.apply(str); 59 | var b = new WrappedOperation(h.randomOperation(strN), meta); 60 | var ab = a.compose(b); 61 | expect(ab.meta.timesComposed).toBe(1); 62 | }); 63 | 64 | it('Transform', helpers.randomTest(n, function() { 65 | var str = h.randomString(50); 66 | var metaA = {}; 67 | var a = new WrappedOperation(h.randomOperation(str), metaA); 68 | var metaB = {}; 69 | var b = new WrappedOperation(h.randomOperation(str), metaB); 70 | var pair = a.transform(b); 71 | var aPrime = pair[0]; 72 | var bPrime = pair[1]; 73 | expect(aPrime.meta).toBe(metaA); 74 | expect(bPrime.meta).toBe(metaB); 75 | expect(aPrime.apply(b.apply(str))).toBe(bPrime.apply(a.apply(str))); 76 | })); 77 | 78 | it('TransformMethod', function() { 79 | var str = 'Loorem ipsum'; 80 | var a = new WrappedOperation( 81 | new TextOperation().retain(1)['delete'](1).retain(10), 82 | new Cursor(1, 1) 83 | ); 84 | var b = new WrappedOperation( 85 | new TextOperation().retain(7)['delete'](1).insert("I").retain(4), 86 | new Cursor(8, 8) 87 | ); 88 | var pair = a.transform(b); 89 | var aPrime = pair[0]; 90 | var bPrime = pair[1]; 91 | expect("Lorem Ipsum").toBe(bPrime.apply(a.apply(str))); 92 | expect(aPrime.meta.equals(new Cursor(1, 1))).toBeTruthy(); 93 | expect(bPrime.meta.equals(new Cursor(7, 7))).toBeTruthy(); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /examples/userlist.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 35 | 36 | 37 | 38 |
39 |
40 | 41 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /lib/undo-manager.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | 3 | firepad.UndoManager = (function () { 4 | 'use strict'; 5 | 6 | var NORMAL_STATE = 'normal'; 7 | var UNDOING_STATE = 'undoing'; 8 | var REDOING_STATE = 'redoing'; 9 | 10 | // Create a new UndoManager with an optional maximum history size. 11 | function UndoManager (maxItems) { 12 | this.maxItems = maxItems || 50; 13 | this.state = NORMAL_STATE; 14 | this.dontCompose = false; 15 | this.undoStack = []; 16 | this.redoStack = []; 17 | } 18 | 19 | // Add an operation to the undo or redo stack, depending on the current state 20 | // of the UndoManager. The operation added must be the inverse of the last 21 | // edit. When `compose` is true, compose the operation with the last operation 22 | // unless the last operation was alread pushed on the redo stack or was hidden 23 | // by a newer operation on the undo stack. 24 | UndoManager.prototype.add = function (operation, compose) { 25 | if (this.state === UNDOING_STATE) { 26 | this.redoStack.push(operation); 27 | this.dontCompose = true; 28 | } else if (this.state === REDOING_STATE) { 29 | this.undoStack.push(operation); 30 | this.dontCompose = true; 31 | } else { 32 | var undoStack = this.undoStack; 33 | if (!this.dontCompose && compose && undoStack.length > 0) { 34 | undoStack.push(operation.compose(undoStack.pop())); 35 | } else { 36 | undoStack.push(operation); 37 | if (undoStack.length > this.maxItems) { undoStack.shift(); } 38 | } 39 | this.dontCompose = false; 40 | this.redoStack = []; 41 | } 42 | }; 43 | 44 | function transformStack (stack, operation) { 45 | var newStack = []; 46 | var Operation = operation.constructor; 47 | for (var i = stack.length - 1; i >= 0; i--) { 48 | var pair = Operation.transform(stack[i], operation); 49 | if (typeof pair[0].isNoop !== 'function' || !pair[0].isNoop()) { 50 | newStack.push(pair[0]); 51 | } 52 | operation = pair[1]; 53 | } 54 | return newStack.reverse(); 55 | } 56 | 57 | // Transform the undo and redo stacks against a operation by another client. 58 | UndoManager.prototype.transform = function (operation) { 59 | this.undoStack = transformStack(this.undoStack, operation); 60 | this.redoStack = transformStack(this.redoStack, operation); 61 | }; 62 | 63 | // Perform an undo by calling a function with the latest operation on the undo 64 | // stack. The function is expected to call the `add` method with the inverse 65 | // of the operation, which pushes the inverse on the redo stack. 66 | UndoManager.prototype.performUndo = function (fn) { 67 | this.state = UNDOING_STATE; 68 | if (this.undoStack.length === 0) { throw new Error("undo not possible"); } 69 | fn(this.undoStack.pop()); 70 | this.state = NORMAL_STATE; 71 | }; 72 | 73 | // The inverse of `performUndo`. 74 | UndoManager.prototype.performRedo = function (fn) { 75 | this.state = REDOING_STATE; 76 | if (this.redoStack.length === 0) { throw new Error("redo not possible"); } 77 | fn(this.redoStack.pop()); 78 | this.state = NORMAL_STATE; 79 | }; 80 | 81 | // Is the undo stack not empty? 82 | UndoManager.prototype.canUndo = function () { 83 | return this.undoStack.length !== 0; 84 | }; 85 | 86 | // Is the redo stack not empty? 87 | UndoManager.prototype.canRedo = function () { 88 | return this.redoStack.length !== 0; 89 | }; 90 | 91 | // Whether the UndoManager is currently performing an undo. 92 | UndoManager.prototype.isUndoing = function () { 93 | return this.state === UNDOING_STATE; 94 | }; 95 | 96 | // Whether the UndoManager is currently performing a redo. 97 | UndoManager.prototype.isRedoing = function () { 98 | return this.state === REDOING_STATE; 99 | }; 100 | 101 | return UndoManager; 102 | 103 | }()); 104 | -------------------------------------------------------------------------------- /.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 | 12 | ## Have a usage question? 13 | 14 | We get lots of those and we love helping you, but GitHub is not the best place for them. Issues 15 | which just ask about usage will be closed. Here are some resources to get help: 16 | 17 | - Go through the [documentation](https://firepad.io/docs/) 18 | - Try out some [examples](../examples/README.md) 19 | 20 | If the official documentation doesn't help, try asking a question on the 21 | [Firebase Google Group](https://groups.google.com/forum/#!forum/firebase-talk) or one of our 22 | other [official support channels](https://firebase.google.com/support/). 23 | 24 | **Please avoid double posting across multiple channels!** 25 | 26 | 27 | ## Think you found a bug? 28 | 29 | Yeah, we're definitely not perfect! 30 | 31 | Search through [old issues](https://github.com/firebase/firepad/issues) before submitting a new 32 | issue as your question may have already been answered. 33 | 34 | If your issue appears to be a bug, and hasn't been reported, 35 | [open a new issue](https://github.com/firebase/firepad/issues/new). Please use the provided bug 36 | report template and include a minimal repro. 37 | 38 | If you are up to the challenge, [submit a pull request](#submit) with a fix! 39 | 40 | 41 | ## Have a feature request? 42 | 43 | Great, we love hearing how we can improve our products! After making sure someone hasn't already 44 | requested the feature in the [existing issues](https://github.com/firebase/firepad/issues), go 45 | ahead and [open a new issue](https://github.com/firebase/firepad/issues/new). Feel free to remove 46 | the bug report template and instead provide an explanation of your feature request. Provide code 47 | samples if applicable. Try to think about what it will allow you to do that you can't do today? How 48 | will it make current workarounds straightforward? What potential bugs and edge cases does it help to 49 | avoid? 50 | 51 | 52 | ## Want to submit a pull request? 53 | 54 | Sweet, we'd love to accept your contribution! [Open a new pull request](https://github.com/firebase/firepad/pull/new/master) 55 | and fill out the provided form. 56 | 57 | **If you want to implement a new feature, please open an issue with a proposal first so that we can 58 | figure out if the feature makes sense and how it will work.** 59 | 60 | Make sure your changes pass our linter and the tests all pass on your local machine. We've hooked 61 | up this repo with continuous integration to double check those things for you. 62 | 63 | Most non-trivial changes should include some extra test coverage. If you aren't sure how to add 64 | tests, feel free to submit regardless and ask us for some advice. 65 | 66 | Finally, you will need to sign our [Contributor License Agreement](https://cla.developers.google.com/about/google-individual) 67 | before we can accept your pull request. 68 | 69 | 70 | ## Need to get set up locally? 71 | 72 | If you'd like to contribute to Firepad, you'll need to do the following to get your environment 73 | set up. 74 | 75 | ### Install Dependencies 76 | 77 | ```bash 78 | $ git clone https://github.com/firebase/firepad.git 79 | $ cd firepad # go to the firepad directory 80 | $ npm install -g grunt-cli # globally install grunt task runner 81 | $ npm install # install local npm build / test dependencies 82 | $ grunt coffee # build coffee once initially (so tests will work) 83 | ``` 84 | 85 | ### Lint, Build, and Test 86 | 87 | ```bash 88 | $ grunt # lint, build, and test 89 | 90 | $ grunt build # lint and build 91 | $ grunt test # just test 92 | 93 | $ grunt watch # lint and build whenever source files change 94 | ``` 95 | 96 | The output files are written to the `/dist/` directory. 97 | -------------------------------------------------------------------------------- /tools/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # release.sh - Firepad release script. 3 | 4 | set -o nounset 5 | set -o errexit 6 | 7 | # Go to repo root. 8 | script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 9 | cd "${script_dir}/.."; 10 | 11 | print_usage() { 12 | echo "$0 " 13 | echo 14 | echo "Arguments:" 15 | echo " version: 'patch', 'minor', or 'major'." 16 | exit 1 17 | } 18 | 19 | exit_on_error() { 20 | echo $1 21 | exit 1 22 | } 23 | 24 | if [[ $# -gt 0 ]]; then 25 | release_type="$1" 26 | else 27 | print_usage 28 | fi 29 | if [[ ! ($release_type == "patch" || $release_type == "minor" || $release_type == "major") ]]; then 30 | print_usage 31 | fi 32 | 33 | which hub &> /dev/null || exit_on_error "Missing hub command. https://github.com/github/hub#installation" 34 | which jekyll &> /dev/null || exit_on_error "Missing jekyll, needed fire firepad.io deploy. See https://github.com/FirebaseExtended/firepad/blob/gh-pages/README.md" 35 | 36 | branch_name="$(git symbolic-ref HEAD 2>/dev/null)" || 37 | branch_name="(unnamed branch)" # detached HEAD 38 | branch_name=${branch_name##refs/heads/} 39 | if [[ ! ($branch_name == "master") ]]; then 40 | exit_on_error "Releases must be run on master branch." 41 | fi 42 | 43 | git diff-index --quiet HEAD || exit_on_error "Modified files present; please commit or revert them." 44 | 45 | echo 46 | echo "Running npm install, build, and test..." 47 | npm install 48 | # Make sure there were no package-lock.json changes. 49 | git diff-index --quiet HEAD || exit_on_error "Modified files present; please commit or revert them." 50 | npm run build 51 | npm run test 52 | 53 | echo "Tests passed." 54 | echo 55 | 56 | last_release=$(git describe --tags --abbrev=0) 57 | echo "Last Release: ${last_release}" 58 | echo 59 | echo "Commits Since Last Release:" 60 | git log "${last_release}..HEAD" --oneline 61 | 62 | echo 63 | echo "Current CHANGELOG.md Contents:" 64 | cat CHANGELOG.md 65 | echo 66 | echo "Does CHANGELOG.md look correct?" 67 | echo " to continue, Ctrl-C to abort (then update and commit it)." 68 | read 69 | 70 | 71 | echo 72 | echo "Logging into npm via wombat-dressing-room (see http://go/npm-publish)." 73 | echo " Press to open browser, then click 'Create 24 hour token'." 74 | echo " If you can't open a browser, try logging in from a different machine:" 75 | echo " npm login --registry https://wombat-dressing-room.appspot.com" 76 | echo " And then copy/paste the resulting ~/.npmrc contents here:" 77 | echo " (this will overwrite your current ~/.npmrc)" 78 | read npmrc 79 | 80 | if [[ ! $npmrc == "" ]]; then 81 | echo $npmrc > ~/.npmrc 82 | else 83 | npm login --registry https://wombat-dressing-room.appspot.com 84 | fi 85 | 86 | echo 87 | echo "Bumping version (update package.json and create tag)." 88 | npm version ${release_type} 89 | new_release=$(git describe --tags --abbrev=0) 90 | echo "New Version: ${new_release}" 91 | 92 | echo 93 | echo "Publishing ${new_release} to npm." 94 | npm publish --registry https://wombat-dressing-room.appspot.com 95 | 96 | # Create a separate release notes file to be included in the github release. 97 | release_notes_file=$(mktemp) 98 | echo "${new_release}" >> "${release_notes_file}" 99 | echo >> "${release_notes_file}" 100 | cat CHANGELOG.md >> "${release_notes_file}" 101 | echo ${release_notes_file} 102 | 103 | echo 104 | echo "Clearing CHANGELOG.md." 105 | echo > CHANGELOG.md 106 | git commit -m "[firepad-release] Cleared CHANGELOG.md after ${new_release} release." CHANGELOG.md 107 | 108 | echo 109 | echo "Pushing changes to GitHub." 110 | git push origin master --tags 111 | 112 | echo 113 | echo "Creating GitHub release." 114 | hub release create \ 115 | -F "${release_notes_file}" \ 116 | -a dist/firepad.js \ 117 | -a dist/firepad.min.js \ 118 | -a dist/firepad.css \ 119 | -a dist/firepad.eot \ 120 | "${new_release}" 121 | 122 | echo 123 | echo "Done. ${new_release} pushed to npm and GitHub. To publish assets to firepad.io run:" 124 | echo " git checkout gh-pages && git pull && ./update-firepad.sh ${new_release}" 125 | -------------------------------------------------------------------------------- /lib/entity-manager.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | 3 | firepad.EntityManager = (function () { 4 | var utils = firepad.utils; 5 | 6 | function EntityManager() { 7 | this.entities_ = {}; 8 | 9 | var attrs = ['src', 'alt', 'width', 'height', 'style', 'class']; 10 | this.register('img', { 11 | render: function(info) { 12 | utils.assert(info.src, "image entity should have 'src'!"); 13 | var attrs = ['src', 'alt', 'width', 'height', 'style', 'class']; 14 | var html = ' 0 && 14 | last(this.undoManager.undoStack).invert(this.doc).shouldBeComposedWith(operation); 15 | this.undoManager.add(operation.invert(this.doc), compose); 16 | this.doc = operation.apply(this.doc); 17 | }; 18 | 19 | Editor.prototype.serverEdit = function (operation) { 20 | this.doc = operation.apply(this.doc); 21 | this.undoManager.transform(operation); 22 | }; 23 | 24 | it('UndoManager', function() { 25 | var editor = new Editor("Looremipsum"); 26 | var undoManager = editor.undoManager; 27 | editor.undo = function () { 28 | expect(undoManager.isUndoing()).toBe(false); 29 | undoManager.performUndo(function (operation) { 30 | expect(undoManager.isUndoing()).toBe(true); 31 | editor.doEdit(operation); 32 | }); 33 | expect(undoManager.isUndoing()).toBe(false); 34 | }; 35 | editor.redo = function () { 36 | expect(undoManager.isRedoing()).toBe(false); 37 | undoManager.performRedo(function (operation) { 38 | expect(undoManager.isRedoing()).toBe(true); 39 | editor.doEdit(operation); 40 | }); 41 | expect(undoManager.isRedoing()).toBe(false); 42 | }; 43 | 44 | expect(undoManager.canUndo()).toBe(false); 45 | expect(undoManager.canRedo()).toBe(false); 46 | editor.doEdit(new TextOperation().retain(2)['delete'](1).retain(8)); 47 | expect(editor.doc).toBe("Loremipsum"); 48 | expect(undoManager.canUndo()).toBe(true); 49 | expect(undoManager.canRedo()).toBe(false); 50 | editor.doEdit(new TextOperation().retain(5).insert(" ").retain(5)); 51 | expect(editor.doc).toBe("Lorem ipsum"); 52 | editor.serverEdit(new TextOperation().retain(6)['delete'](1).insert("I").retain(4)); 53 | expect(editor.doc).toBe("Lorem Ipsum"); 54 | editor.undo(); 55 | expect(editor.doc).toBe("LoremIpsum"); 56 | expect(undoManager.canUndo()).toBe(true); 57 | expect(undoManager.canRedo()).toBe(true); 58 | expect(1).toBe(undoManager.undoStack.length); 59 | expect(1).toBe(undoManager.redoStack.length); 60 | editor.undo(); 61 | expect(undoManager.canUndo()).toBe(false); 62 | expect(undoManager.canRedo()).toBe(true); 63 | expect(editor.doc).toBe("LooremIpsum"); 64 | editor.redo(); 65 | expect(editor.doc).toBe("LoremIpsum"); 66 | editor.doEdit(new TextOperation().retain(10).insert("D")); 67 | expect(editor.doc).toBe("LoremIpsumD"); 68 | expect(undoManager.canRedo()).toBe(false); 69 | editor.doEdit(new TextOperation().retain(11).insert("o")); 70 | editor.doEdit(new TextOperation().retain(12).insert("l")); 71 | editor.undo(); 72 | expect(editor.doc).toBe("LoremIpsum"); 73 | editor.redo(); 74 | expect(editor.doc).toBe("LoremIpsumDol"); 75 | editor.doEdit(new TextOperation().retain(13).insert("o")); 76 | editor.undo(); 77 | expect(editor.doc).toBe("LoremIpsumDol"); 78 | editor.doEdit(new TextOperation().retain(13).insert("o")); 79 | editor.doEdit(new TextOperation().retain(14).insert("r"), true); 80 | editor.undo(); 81 | expect(editor.doc).toBe("LoremIpsumDolo"); 82 | expect(undoManager.canRedo()).toBe(true); 83 | editor.serverEdit(new TextOperation().retain(10)['delete'](4)); 84 | editor.redo(); 85 | expect(editor.doc).toBe("LoremIpsumr"); 86 | editor.undo(); 87 | editor.undo(); 88 | expect(editor.doc).toBe("LooremIpsum"); 89 | }); 90 | 91 | it('UndoManagerMaxItems', function() { 92 | var doc = h.randomString(50); 93 | var undoManager = new UndoManager(42); 94 | var operation; 95 | for (var i = 0; i < 100; i++) { 96 | operation = h.randomOperation(doc); 97 | doc = operation.apply(doc); 98 | undoManager.add(operation); 99 | } 100 | expect(undoManager.undoStack.length).toBe(42); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /test/specs/client.spec.js: -------------------------------------------------------------------------------- 1 | describe('Client', function() { 2 | var TextOperation = firepad.TextOperation; 3 | var Client = firepad.Client; 4 | 5 | it('Client', function() { 6 | var client = new Client(); 7 | expect(client.state instanceof Client.Synchronized).toBe(true); 8 | 9 | var sentOperation = null; 10 | function getSentOperation () { 11 | var a = sentOperation; 12 | if (!a) { throw new Error("sendOperation wasn't called"); } 13 | sentOperation = null; 14 | return a; 15 | } 16 | client.sendOperation = function (operation) { 17 | sentOperation = operation; 18 | }; 19 | 20 | var doc = "lorem dolor"; 21 | var appliedOperation = null; 22 | function getAppliedOperation () { 23 | var a = appliedOperation; 24 | if (!a) { throw new Error("applyOperation wasn't called"); } 25 | appliedOperation = null; 26 | return a; 27 | } 28 | client.applyOperation = function (operation) { 29 | doc = operation.apply(doc); 30 | appliedOperation = operation; 31 | }; 32 | 33 | function applyClient (operation) { 34 | doc = operation.apply(doc); 35 | client.applyClient(operation); 36 | } 37 | 38 | client.applyServer(new TextOperation().retain(6)['delete'](1).insert("D").retain(4)); 39 | expect(doc).toBe("lorem Dolor"); 40 | expect(client.state instanceof Client.Synchronized).toBe(true); 41 | 42 | applyClient(new TextOperation().retain(11).insert(" ")); 43 | expect(doc).toBe("lorem Dolor "); 44 | expect(client.state instanceof Client.AwaitingConfirm).toBe(true); 45 | expect(client.state.outstanding.equals(new TextOperation().retain(11).insert(" "))).toBe(true); 46 | expect(getSentOperation().equals(new TextOperation().retain(11).insert(" "))).toBe(true); 47 | 48 | client.applyServer(new TextOperation().retain(5).insert(" ").retain(6)); 49 | expect(doc).toBe("lorem Dolor "); 50 | expect(client.state instanceof Client.AwaitingConfirm).toBe(true); 51 | expect(client.state.outstanding.equals(new TextOperation().retain(12).insert(" "))).toBe(true); 52 | 53 | applyClient(new TextOperation().retain(13).insert("S")); 54 | expect(client.state instanceof Client.AwaitingWithBuffer).toBe(true); 55 | applyClient(new TextOperation().retain(14).insert("i")); 56 | applyClient(new TextOperation().retain(15).insert("t")); 57 | expect(!sentOperation).toBe(true); 58 | expect(doc).toBe("lorem Dolor Sit"); 59 | expect(client.state.outstanding.equals(new TextOperation().retain(12).insert(" "))).toBe(true); 60 | expect(client.state.buffer.equals(new TextOperation().retain(13).insert("Sit"))).toBe(true); 61 | 62 | client.applyServer(new TextOperation().retain(6).insert("Ipsum").retain(6)); 63 | expect(doc).toBe("lorem Ipsum Dolor Sit"); 64 | expect(client.state instanceof Client.AwaitingWithBuffer).toBe(true); 65 | expect(client.state.outstanding.equals(new TextOperation().retain(17).insert(" "))).toBe(true); 66 | expect(client.state.buffer.equals(new TextOperation().retain(18).insert("Sit"))).toBe(true); 67 | 68 | client.serverAck(); 69 | expect(getSentOperation().equals(new TextOperation().retain(18).insert("Sit"))).toBe(true); 70 | expect(client.state instanceof Client.AwaitingConfirm).toBe(true); 71 | expect(client.state.outstanding.equals(new TextOperation().retain(18).insert("Sit"))).toBe(true); 72 | 73 | client.serverAck(); 74 | expect(client.state instanceof Client.Synchronized).toBe(true); 75 | expect(doc).toBe("lorem Ipsum Dolor Sit"); 76 | 77 | // Test AwaitingConfirm and AwaitingWithBuffer retry operation. 78 | applyClient(new TextOperation().retain(21).insert("a")); 79 | expect(client.state instanceof Client.AwaitingConfirm).toBe(true); 80 | 81 | client.serverRetry(); 82 | expect(sentOperation.equals(new TextOperation().retain(21).insert('a'))).toBe(true); 83 | client.serverAck(); 84 | expect(client.state instanceof Client.Synchronized).toBe(true); 85 | expect(doc).toBe("lorem Ipsum Dolor Sita"); 86 | 87 | applyClient(new TextOperation().retain(22).insert("m")); 88 | expect(client.state instanceof Client.AwaitingConfirm).toBe(true); 89 | applyClient(new TextOperation().retain(23).insert("a")); 90 | expect(client.state instanceof Client.AwaitingWithBuffer).toBe(true); 91 | client.serverRetry(); 92 | expect(sentOperation.equals(new TextOperation().retain(22).insert('ma'))).toBe(true); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | firepad.utils = { }; 3 | 4 | firepad.utils.makeEventEmitter = function(clazz, opt_allowedEVents) { 5 | clazz.prototype.allowedEvents_ = opt_allowedEVents; 6 | 7 | clazz.prototype.on = function(eventType, callback, context) { 8 | this.validateEventType_(eventType); 9 | this.eventListeners_ = this.eventListeners_ || { }; 10 | this.eventListeners_[eventType] = this.eventListeners_[eventType] || []; 11 | this.eventListeners_[eventType].push({ callback: callback, context: context }); 12 | }; 13 | 14 | clazz.prototype.off = function(eventType, callback) { 15 | this.validateEventType_(eventType); 16 | this.eventListeners_ = this.eventListeners_ || { }; 17 | var listeners = this.eventListeners_[eventType] || []; 18 | for(var i = 0; i < listeners.length; i++) { 19 | if (listeners[i].callback === callback) { 20 | listeners.splice(i, 1); 21 | return; 22 | } 23 | } 24 | }; 25 | 26 | clazz.prototype.trigger = function(eventType /*, args ... */) { 27 | this.eventListeners_ = this.eventListeners_ || { }; 28 | var listeners = this.eventListeners_[eventType] || []; 29 | for(var i = 0; i < listeners.length; i++) { 30 | listeners[i].callback.apply(listeners[i].context, Array.prototype.slice.call(arguments, 1)); 31 | } 32 | }; 33 | 34 | clazz.prototype.validateEventType_ = function(eventType) { 35 | if (this.allowedEvents_) { 36 | var allowed = false; 37 | for(var i = 0; i < this.allowedEvents_.length; i++) { 38 | if (this.allowedEvents_[i] === eventType) { 39 | allowed = true; 40 | break; 41 | } 42 | } 43 | if (!allowed) { 44 | throw new Error('Unknown event "' + eventType + '"'); 45 | } 46 | } 47 | }; 48 | }; 49 | 50 | firepad.utils.elt = function(tag, content, attrs) { 51 | var e = document.createElement(tag); 52 | if (typeof content === "string") { 53 | firepad.utils.setTextContent(e, content); 54 | } else if (content) { 55 | for (var i = 0; i < content.length; ++i) { e.appendChild(content[i]); } 56 | } 57 | for(var attr in (attrs || { })) { 58 | e.setAttribute(attr, attrs[attr]); 59 | } 60 | return e; 61 | }; 62 | 63 | firepad.utils.setTextContent = function(e, str) { 64 | e.innerHTML = ""; 65 | e.appendChild(document.createTextNode(str)); 66 | }; 67 | 68 | 69 | firepad.utils.on = function(emitter, type, f, capture) { 70 | if (emitter.addEventListener) { 71 | emitter.addEventListener(type, f, capture || false); 72 | } else if (emitter.attachEvent) { 73 | emitter.attachEvent("on" + type, f); 74 | } 75 | }; 76 | 77 | firepad.utils.off = function(emitter, type, f, capture) { 78 | if (emitter.removeEventListener) { 79 | emitter.removeEventListener(type, f, capture || false); 80 | } else if (emitter.detachEvent) { 81 | emitter.detachEvent("on" + type, f); 82 | } 83 | }; 84 | 85 | firepad.utils.preventDefault = function(e) { 86 | if (e.preventDefault) { 87 | e.preventDefault(); 88 | } else { 89 | e.returnValue = false; 90 | } 91 | }; 92 | 93 | firepad.utils.stopPropagation = function(e) { 94 | if (e.stopPropagation) { 95 | e.stopPropagation(); 96 | } else { 97 | e.cancelBubble = true; 98 | } 99 | }; 100 | 101 | firepad.utils.stopEvent = function(e) { 102 | firepad.utils.preventDefault(e); 103 | firepad.utils.stopPropagation(e); 104 | }; 105 | 106 | firepad.utils.stopEventAnd = function(fn) { 107 | return function(e) { 108 | fn(e); 109 | firepad.utils.stopEvent(e); 110 | return false; 111 | }; 112 | }; 113 | 114 | firepad.utils.trim = function(str) { 115 | return str.replace(/^\s+/g, '').replace(/\s+$/g, ''); 116 | }; 117 | 118 | firepad.utils.stringEndsWith = function(str, suffix) { 119 | var list = (typeof suffix == 'string') ? [suffix] : suffix; 120 | for (var i = 0; i < list.length; i++) { 121 | var suffix = list[i]; 122 | if (str.indexOf(suffix, str.length - suffix.length) !== -1) 123 | return true; 124 | } 125 | return false; 126 | }; 127 | 128 | firepad.utils.assert = function assert (b, msg) { 129 | if (!b) { 130 | throw new Error(msg || "assertion error"); 131 | } 132 | }; 133 | 134 | firepad.utils.log = function() { 135 | if (typeof console !== 'undefined' && typeof console.log !== 'undefined') { 136 | var args = ['Firepad:']; 137 | for(var i = 0; i < arguments.length; i++) { 138 | args.push(arguments[i]); 139 | } 140 | console.log.apply(console, args); 141 | } 142 | }; 143 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | grunt.initConfig({ 3 | coffeelint: { 4 | app: ['lib/*.coffee'], 5 | options: { 6 | max_line_length: { 7 | level: 'ignore' 8 | }, 9 | line_endings: { 10 | value: "unix", 11 | level: "error" 12 | }, 13 | no_trailing_semicolons: { 14 | level: "ignore" 15 | } 16 | } 17 | }, 18 | coffee: { 19 | compile: { 20 | files: [{ 21 | expand: true, // Enable dynamic expansion. 22 | cwd: 'lib/', // Src matches are relative to this path. 23 | src: ['**/*.coffee'], // Actual pattern(s) to match. 24 | dest: 'lib/', // Destination path prefix. 25 | ext: '.js' // Dest filepaths will have this extension. 26 | }], 27 | options: { 28 | bare: true, // Skip surrounding IIFE in compiled output. 29 | transpile: { 30 | presets: ['@babel/preset-env'], // Pass the output through babel 31 | } 32 | } 33 | } 34 | }, 35 | concat: { 36 | "firepadjs": { 37 | options: { 38 | banner: [ 39 | '/*!', 40 | ' * Firepad is an open-source, collaborative code and text editor. It was designed', 41 | ' * to be embedded inside larger applications. Since it uses Firebase as a backend,', 42 | ' * it requires no server-side code and can be added to any web app simply by', 43 | ' * including a couple JavaScript files.', 44 | ' *', 45 | ' * Firepad 0.0.0', 46 | ' * http://www.firepad.io/', 47 | ' * License: MIT', 48 | ' * Copyright: 2014 Firebase', 49 | ' * With code from ot.js (Copyright 2012-2013 Tim Baumann)', 50 | ' */\n', 51 | '(function (name, definition, context) {', 52 | ' //try CommonJS, then AMD (require.js), then use global.', 53 | ' if (typeof module != \'undefined\' && module.exports) module.exports = definition();', 54 | ' else if (typeof context[\'define\'] == \'function\' && context[\'define\'][\'amd\']) define(definition);', 55 | ' else context[name] = definition();', 56 | '})(\'Firepad\', function () {' 57 | ].join('\n'), 58 | footer: "\nreturn firepad.Firepad; }, this);" 59 | }, 60 | "src": [ 61 | "lib/utils.js", 62 | "lib/span.js", 63 | "lib/text-op.js", 64 | "lib/text-operation.js", 65 | "lib/annotation-list.js", 66 | "lib/cursor.js", 67 | "lib/firebase-adapter.js", 68 | "lib/rich-text-toolbar.js", 69 | "lib/wrapped-operation.js", 70 | "lib/undo-manager.js", 71 | "lib/client.js", 72 | "lib/editor-client.js", 73 | "lib/ace-adapter.js", 74 | "lib/monaco-adapter.js", 75 | "lib/constants.js", 76 | "lib/entity-manager.js", 77 | "lib/entity.js", 78 | "lib/rich-text-codemirror.js", 79 | "lib/rich-text-codemirror-adapter.js", 80 | "lib/formatting.js", 81 | "lib/text.js", 82 | "lib/line-formatting.js", 83 | "lib/line.js", 84 | "lib/parse-html.js", 85 | "lib/serialize-html.js", 86 | "lib/text-pieces-to-inserts.js", 87 | "lib/headless.js", 88 | "lib/firepad.js" 89 | ], 90 | "dest": "dist/firepad.js" 91 | } 92 | }, 93 | uglify: { 94 | options: { 95 | preserveComments: "some" 96 | }, 97 | "firepad-min-js": { 98 | src: "dist/firepad.js", 99 | dest: "dist/firepad.min.js" 100 | } 101 | }, 102 | copy: { 103 | toBuild: { 104 | files: [ 105 | { 106 | src: 'font/firepad.eot', 107 | dest: 'dist/firepad.eot' 108 | }, 109 | { 110 | src: 'lib/firepad.css', 111 | dest: 'dist/firepad.css' 112 | }, 113 | ] 114 | } 115 | }, 116 | watch: { 117 | files: ['lib/*.js', 'lib/*.coffee', 'lib/*.css'], 118 | tasks: ['build'] 119 | }, 120 | 121 | // Unit tests 122 | karma: { 123 | options: { 124 | configFile: 'test/karma.conf.js', 125 | }, 126 | unit: { 127 | autowatch: false, 128 | singleRun: true 129 | } 130 | } 131 | }); 132 | 133 | grunt.loadNpmTasks('grunt-coffeelint'); 134 | grunt.loadNpmTasks('grunt-contrib-coffee'); 135 | grunt.loadNpmTasks('grunt-contrib-concat'); 136 | grunt.loadNpmTasks('grunt-contrib-uglify-es'); 137 | grunt.loadNpmTasks('grunt-contrib-copy'); 138 | grunt.loadNpmTasks('grunt-contrib-watch'); 139 | grunt.loadNpmTasks('grunt-karma'); 140 | 141 | // Tasks 142 | grunt.registerTask('test', ['karma:unit']); 143 | grunt.registerTask('build', ['coffeelint', 'coffee', 'concat', 'uglify', 'copy']) 144 | grunt.registerTask('default', ['build', 'test']); 145 | }; 146 | -------------------------------------------------------------------------------- /lib/headless.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | 3 | /** 4 | * Instance of headless Firepad for use in NodeJS. Supports get/set on text/html. 5 | */ 6 | firepad.Headless = (function() { 7 | var TextOperation = firepad.TextOperation; 8 | var FirebaseAdapter = firepad.FirebaseAdapter; 9 | var EntityManager = firepad.EntityManager; 10 | var ParseHtml = firepad.ParseHtml; 11 | 12 | function Headless(refOrPath) { 13 | // Allow calling without new. 14 | if (!(this instanceof Headless)) { return new Headless(refOrPath); } 15 | 16 | var firebase, ref; 17 | if (typeof refOrPath === 'string') { 18 | if (window.firebase === undefined && typeof firebase !== 'object') { 19 | console.log("REQUIRING"); 20 | firebase = require('firebase/app'); 21 | require('firebase/database'); 22 | } else { 23 | firebase = window.firebase; 24 | } 25 | 26 | ref = firebase.database().refFromURL(refOrPath); 27 | } else { 28 | ref = refOrPath; 29 | } 30 | 31 | this.entityManager_ = new EntityManager(); 32 | 33 | this.firebaseAdapter_ = new FirebaseAdapter(ref); 34 | this.ready_ = false; 35 | this.zombie_ = false; 36 | } 37 | 38 | Headless.prototype.getDocument = function(callback) { 39 | var self = this; 40 | 41 | if (self.ready_) { 42 | return callback(self.firebaseAdapter_.getDocument()); 43 | } 44 | 45 | self.firebaseAdapter_.on('ready', function() { 46 | self.ready_ = true; 47 | callback(self.firebaseAdapter_.getDocument()); 48 | }); 49 | } 50 | 51 | Headless.prototype.getText = function(callback) { 52 | if (this.zombie_) { 53 | throw new Error('You can\'t use a firepad.Headless after calling dispose()!'); 54 | } 55 | 56 | this.getDocument(function(doc) { 57 | var text = doc.apply(''); 58 | 59 | // Strip out any special characters from Rich Text formatting 60 | for (var key in firepad.sentinelConstants) { 61 | text = text.replace(new RegExp(firepad.sentinelConstants[key], 'g'), ''); 62 | } 63 | callback(text); 64 | }); 65 | } 66 | 67 | Headless.prototype.setText = function(text, callback) { 68 | if (this.zombie_) { 69 | throw new Error('You can\'t use a firepad.Headless after calling dispose()!'); 70 | } 71 | 72 | var op = TextOperation().insert(text); 73 | this.sendOperationWithRetry(op, callback); 74 | } 75 | 76 | Headless.prototype.initializeFakeDom = function(callback) { 77 | if (typeof document === 'object' || typeof firepad.document === 'object') { 78 | callback(); 79 | } else { 80 | const jsdom = require('jsdom'); 81 | const { JSDOM } = jsdom; 82 | const { window } = new JSDOM(""); 83 | if (firepad.document) { 84 | // Return if we've already made a jsdom to avoid making more than one 85 | // This would be easier with promises but we want to avoid introducing 86 | // another dependency for just headless mode. 87 | window.close(); 88 | return callback(); 89 | } 90 | firepad.document = window.document; 91 | callback(); 92 | } 93 | } 94 | 95 | Headless.prototype.getHtml = function(callback) { 96 | var self = this; 97 | 98 | if (this.zombie_) { 99 | throw new Error('You can\'t use a firepad.Headless after calling dispose()!'); 100 | } 101 | 102 | self.initializeFakeDom(function() { 103 | self.getDocument(function(doc) { 104 | callback(firepad.SerializeHtml(doc, self.entityManager_)); 105 | }); 106 | }); 107 | } 108 | 109 | Headless.prototype.setHtml = function(html, callback) { 110 | var self = this; 111 | 112 | if (this.zombie_) { 113 | throw new Error('You can\'t use a firepad.Headless after calling dispose()!'); 114 | } 115 | 116 | self.initializeFakeDom(function() { 117 | var textPieces = ParseHtml(html, self.entityManager_); 118 | var inserts = firepad.textPiecesToInserts(true, textPieces); 119 | var op = new TextOperation(); 120 | 121 | for (var i = 0; i < inserts.length; i++) { 122 | op.insert(inserts[i].string, inserts[i].attributes); 123 | } 124 | 125 | self.sendOperationWithRetry(op, callback); 126 | }); 127 | } 128 | 129 | Headless.prototype.sendOperationWithRetry = function(operation, callback) { 130 | var self = this; 131 | 132 | self.getDocument(function(doc) { 133 | var op = operation.clone()['delete'](doc.targetLength); 134 | self.firebaseAdapter_.sendOperation(op, function(err, committed) { 135 | if (committed) { 136 | if (typeof callback !== "undefined") { 137 | callback(null, committed); 138 | } 139 | } else { 140 | self.sendOperationWithRetry(operation, callback); 141 | } 142 | }); 143 | }); 144 | } 145 | 146 | Headless.prototype.dispose = function() { 147 | this.zombie_ = true; // We've been disposed. No longer valid to do anything. 148 | 149 | this.firebaseAdapter_.dispose(); 150 | }; 151 | 152 | return Headless; 153 | })(); 154 | -------------------------------------------------------------------------------- /examples/richtext.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 28 | 29 | 30 | 31 |
32 | 33 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /lib/client.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | firepad.Client = (function () { 3 | 'use strict'; 4 | 5 | // Client constructor 6 | function Client () { 7 | this.state = synchronized_; // start state 8 | } 9 | 10 | Client.prototype.setState = function (state) { 11 | this.state = state; 12 | }; 13 | 14 | // Call this method when the user changes the document. 15 | Client.prototype.applyClient = function (operation) { 16 | this.setState(this.state.applyClient(this, operation)); 17 | }; 18 | 19 | // Call this method with a new operation from the server 20 | Client.prototype.applyServer = function (operation) { 21 | this.setState(this.state.applyServer(this, operation)); 22 | }; 23 | 24 | Client.prototype.serverAck = function () { 25 | this.setState(this.state.serverAck(this)); 26 | }; 27 | 28 | Client.prototype.serverRetry = function() { 29 | this.setState(this.state.serverRetry(this)); 30 | }; 31 | 32 | // Override this method. 33 | Client.prototype.sendOperation = function (operation) { 34 | throw new Error("sendOperation must be defined in child class"); 35 | }; 36 | 37 | // Override this method. 38 | Client.prototype.applyOperation = function (operation) { 39 | throw new Error("applyOperation must be defined in child class"); 40 | }; 41 | 42 | 43 | // In the 'Synchronized' state, there is no pending operation that the client 44 | // has sent to the server. 45 | function Synchronized () {} 46 | Client.Synchronized = Synchronized; 47 | 48 | Synchronized.prototype.applyClient = function (client, operation) { 49 | // When the user makes an edit, send the operation to the server and 50 | // switch to the 'AwaitingConfirm' state 51 | client.sendOperation(operation); 52 | return new AwaitingConfirm(operation); 53 | }; 54 | 55 | Synchronized.prototype.applyServer = function (client, operation) { 56 | // When we receive a new operation from the server, the operation can be 57 | // simply applied to the current document 58 | client.applyOperation(operation); 59 | return this; 60 | }; 61 | 62 | Synchronized.prototype.serverAck = function (client) { 63 | throw new Error("There is no pending operation."); 64 | }; 65 | 66 | Synchronized.prototype.serverRetry = function(client) { 67 | throw new Error("There is no pending operation."); 68 | }; 69 | 70 | // Singleton 71 | var synchronized_ = new Synchronized(); 72 | 73 | 74 | // In the 'AwaitingConfirm' state, there's one operation the client has sent 75 | // to the server and is still waiting for an acknowledgement. 76 | function AwaitingConfirm (outstanding) { 77 | // Save the pending operation 78 | this.outstanding = outstanding; 79 | } 80 | Client.AwaitingConfirm = AwaitingConfirm; 81 | 82 | AwaitingConfirm.prototype.applyClient = function (client, operation) { 83 | // When the user makes an edit, don't send the operation immediately, 84 | // instead switch to 'AwaitingWithBuffer' state 85 | return new AwaitingWithBuffer(this.outstanding, operation); 86 | }; 87 | 88 | AwaitingConfirm.prototype.applyServer = function (client, operation) { 89 | // This is another client's operation. Visualization: 90 | // 91 | // /\ 92 | // this.outstanding / \ operation 93 | // / \ 94 | // \ / 95 | // pair[1] \ / pair[0] (new outstanding) 96 | // (can be applied \/ 97 | // to the client's 98 | // current document) 99 | var pair = this.outstanding.transform(operation); 100 | client.applyOperation(pair[1]); 101 | return new AwaitingConfirm(pair[0]); 102 | }; 103 | 104 | AwaitingConfirm.prototype.serverAck = function (client) { 105 | // The client's operation has been acknowledged 106 | // => switch to synchronized state 107 | return synchronized_; 108 | }; 109 | 110 | AwaitingConfirm.prototype.serverRetry = function (client) { 111 | client.sendOperation(this.outstanding); 112 | return this; 113 | }; 114 | 115 | // In the 'AwaitingWithBuffer' state, the client is waiting for an operation 116 | // to be acknowledged by the server while buffering the edits the user makes 117 | function AwaitingWithBuffer (outstanding, buffer) { 118 | // Save the pending operation and the user's edits since then 119 | this.outstanding = outstanding; 120 | this.buffer = buffer; 121 | } 122 | Client.AwaitingWithBuffer = AwaitingWithBuffer; 123 | 124 | AwaitingWithBuffer.prototype.applyClient = function (client, operation) { 125 | // Compose the user's changes onto the buffer 126 | var newBuffer = this.buffer.compose(operation); 127 | return new AwaitingWithBuffer(this.outstanding, newBuffer); 128 | }; 129 | 130 | AwaitingWithBuffer.prototype.applyServer = function (client, operation) { 131 | // Operation comes from another client 132 | // 133 | // /\ 134 | // this.outstanding / \ operation 135 | // / \ 136 | // /\ / 137 | // this.buffer / \* / pair1[0] (new outstanding) 138 | // / \/ 139 | // \ / 140 | // pair2[1] \ / pair2[0] (new buffer) 141 | // the transformed \/ 142 | // operation -- can 143 | // be applied to the 144 | // client's current 145 | // document 146 | // 147 | // * pair1[1] 148 | var pair1 = this.outstanding.transform(operation); 149 | var pair2 = this.buffer.transform(pair1[1]); 150 | client.applyOperation(pair2[1]); 151 | return new AwaitingWithBuffer(pair1[0], pair2[0]); 152 | }; 153 | 154 | AwaitingWithBuffer.prototype.serverRetry = function (client) { 155 | // Merge with our buffer and resend. 156 | var outstanding = this.outstanding.compose(this.buffer); 157 | client.sendOperation(outstanding); 158 | return new AwaitingConfirm(outstanding); 159 | }; 160 | 161 | AwaitingWithBuffer.prototype.serverAck = function (client) { 162 | // The pending operation has been acknowledged 163 | // => send buffer 164 | client.sendOperation(this.buffer); 165 | return new AwaitingConfirm(this.buffer); 166 | }; 167 | 168 | return Client; 169 | 170 | }()); 171 | -------------------------------------------------------------------------------- /examples/hammer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Collaborative editing with CodeMirror 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 29 | 30 | 31 | 32 |

Firepad

33 |
34 | Hammer Time! 35 | 36 | 37 | 180 | 181 | 182 | 183 | -------------------------------------------------------------------------------- /lib/rich-text-toolbar.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | 3 | firepad.RichTextToolbar = (function(global) { 4 | var utils = firepad.utils; 5 | 6 | function RichTextToolbar(imageInsertionUI) { 7 | this.imageInsertionUI = imageInsertionUI; 8 | this.element_ = this.makeElement_(); 9 | } 10 | 11 | utils.makeEventEmitter(RichTextToolbar, ['bold', 'italic', 'underline', 'strike', 'font', 'font-size', 'color', 12 | 'left', 'center', 'right', 'unordered-list', 'ordered-list', 'todo-list', 'indent-increase', 'indent-decrease', 13 | 'undo', 'redo', 'insert-image']); 14 | 15 | RichTextToolbar.prototype.element = function() { return this.element_; }; 16 | 17 | RichTextToolbar.prototype.makeButton_ = function(eventName, iconName) { 18 | var self = this; 19 | iconName = iconName || eventName; 20 | var btn = utils.elt('a', [utils.elt('span', '', { 'class': 'firepad-tb-' + iconName } )], { 'class': 'firepad-btn' }); 21 | utils.on(btn, 'click', utils.stopEventAnd(function() { self.trigger(eventName); })); 22 | return btn; 23 | } 24 | 25 | RichTextToolbar.prototype.makeElement_ = function() { 26 | var self = this; 27 | 28 | var font = this.makeFontDropdown_(); 29 | var fontSize = this.makeFontSizeDropdown_(); 30 | var color = this.makeColorDropdown_(); 31 | 32 | var toolbarOptions = [ 33 | utils.elt('div', [font], { 'class': 'firepad-btn-group'}), 34 | utils.elt('div', [fontSize], { 'class': 'firepad-btn-group'}), 35 | utils.elt('div', [color], { 'class': 'firepad-btn-group'}), 36 | utils.elt('div', [self.makeButton_('bold'), self.makeButton_('italic'), self.makeButton_('underline'), self.makeButton_('strike', 'strikethrough')], { 'class': 'firepad-btn-group'}), 37 | utils.elt('div', [self.makeButton_('unordered-list', 'list-2'), self.makeButton_('ordered-list', 'numbered-list'), self.makeButton_('todo-list', 'list')], { 'class': 'firepad-btn-group'}), 38 | utils.elt('div', [self.makeButton_('indent-decrease'), self.makeButton_('indent-increase')], { 'class': 'firepad-btn-group'}), 39 | utils.elt('div', [self.makeButton_('left', 'paragraph-left'), self.makeButton_('center', 'paragraph-center'), self.makeButton_('right', 'paragraph-right')], { 'class': 'firepad-btn-group'}), 40 | utils.elt('div', [self.makeButton_('undo'), self.makeButton_('redo')], { 'class': 'firepad-btn-group'}) 41 | ]; 42 | 43 | if (self.imageInsertionUI) { 44 | toolbarOptions.push(utils.elt('div', [self.makeButton_('insert-image')], { 'class': 'firepad-btn-group' })); 45 | } 46 | 47 | var toolbarWrapper = utils.elt('div', toolbarOptions, { 'class': 'firepad-toolbar-wrapper' }); 48 | var toolbar = utils.elt('div', null, { 'class': 'firepad-toolbar' }); 49 | toolbar.appendChild(toolbarWrapper) 50 | 51 | return toolbar; 52 | }; 53 | 54 | RichTextToolbar.prototype.makeFontDropdown_ = function() { 55 | // NOTE: There must be matching .css styles in firepad.css. 56 | var fonts = ['Arial', 'Comic Sans MS', 'Courier New', 'Impact', 'Times New Roman', 'Verdana']; 57 | 58 | var items = []; 59 | for(var i = 0; i < fonts.length; i++) { 60 | var content = utils.elt('span', fonts[i]); 61 | content.setAttribute('style', 'font-family:' + fonts[i]); 62 | items.push({ content: content, value: fonts[i] }); 63 | } 64 | return this.makeDropdown_('Font', 'font', items); 65 | }; 66 | 67 | RichTextToolbar.prototype.makeFontSizeDropdown_ = function() { 68 | // NOTE: There must be matching .css styles in firepad.css. 69 | var sizes = [9, 10, 12, 14, 18, 24, 32, 42]; 70 | 71 | var items = []; 72 | for(var i = 0; i < sizes.length; i++) { 73 | var content = utils.elt('span', sizes[i].toString()); 74 | content.setAttribute('style', 'font-size:' + sizes[i] + 'px; line-height:' + (sizes[i]-6) + 'px;'); 75 | items.push({ content: content, value: sizes[i] }); 76 | } 77 | return this.makeDropdown_('Size', 'font-size', items, 'px'); 78 | }; 79 | 80 | RichTextToolbar.prototype.makeColorDropdown_ = function() { 81 | var colors = ['black', 'red', 'green', 'blue', 'yellow', 'cyan', 'magenta', 'grey']; 82 | 83 | var items = []; 84 | for(var i = 0; i < colors.length; i++) { 85 | var content = utils.elt('div'); 86 | content.className = 'firepad-color-dropdown-item'; 87 | content.setAttribute('style', 'background-color:' + colors[i]); 88 | items.push({ content: content, value: colors[i] }); 89 | } 90 | return this.makeDropdown_('Color', 'color', items); 91 | }; 92 | 93 | RichTextToolbar.prototype.makeDropdown_ = function(title, eventName, items, value_suffix) { 94 | value_suffix = value_suffix || ""; 95 | var self = this; 96 | var button = utils.elt('a', title + ' \u25be', { 'class': 'firepad-btn firepad-dropdown' }); 97 | var list = utils.elt('ul', [ ], { 'class': 'firepad-dropdown-menu' }); 98 | button.appendChild(list); 99 | 100 | var isShown = false; 101 | function showDropdown() { 102 | if (!isShown) { 103 | list.style.display = 'block'; 104 | utils.on(document, 'click', hideDropdown, /*capture=*/true); 105 | isShown = true; 106 | } 107 | } 108 | 109 | var justDismissed = false; 110 | function hideDropdown() { 111 | if (isShown) { 112 | list.style.display = ''; 113 | utils.off(document, 'click', hideDropdown, /*capture=*/true); 114 | isShown = false; 115 | } 116 | // HACK so we can avoid re-showing the dropdown if you click on the dropdown header to dismiss it. 117 | justDismissed = true; 118 | setTimeout(function() { justDismissed = false; }, 0); 119 | } 120 | 121 | function addItem(content, value) { 122 | if (typeof content !== 'object') { 123 | content = document.createTextNode(String(content)); 124 | } 125 | var element = utils.elt('a', [content]); 126 | 127 | utils.on(element, 'click', utils.stopEventAnd(function() { 128 | hideDropdown(); 129 | self.trigger(eventName, value + value_suffix); 130 | })); 131 | 132 | list.appendChild(element); 133 | } 134 | 135 | for(var i = 0; i < items.length; i++) { 136 | var content = items[i].content, value = items[i].value; 137 | addItem(content, value); 138 | } 139 | 140 | utils.on(button, 'click', utils.stopEventAnd(function() { 141 | if (!justDismissed) { 142 | showDropdown(); 143 | } 144 | })); 145 | 146 | return button; 147 | }; 148 | 149 | return RichTextToolbar; 150 | })(); 151 | -------------------------------------------------------------------------------- /lib/editor-client.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | 3 | firepad.EditorClient = (function () { 4 | 'use strict'; 5 | 6 | var Client = firepad.Client; 7 | var Cursor = firepad.Cursor; 8 | var UndoManager = firepad.UndoManager; 9 | var WrappedOperation = firepad.WrappedOperation; 10 | 11 | function SelfMeta (cursorBefore, cursorAfter) { 12 | this.cursorBefore = cursorBefore; 13 | this.cursorAfter = cursorAfter; 14 | } 15 | 16 | SelfMeta.prototype.invert = function () { 17 | return new SelfMeta(this.cursorAfter, this.cursorBefore); 18 | }; 19 | 20 | SelfMeta.prototype.compose = function (other) { 21 | return new SelfMeta(this.cursorBefore, other.cursorAfter); 22 | }; 23 | 24 | SelfMeta.prototype.transform = function (operation) { 25 | return new SelfMeta( 26 | this.cursorBefore ? this.cursorBefore.transform(operation) : null, 27 | this.cursorAfter ? this.cursorAfter.transform(operation) : null 28 | ); 29 | }; 30 | 31 | function OtherClient (id, editorAdapter) { 32 | this.id = id; 33 | this.editorAdapter = editorAdapter; 34 | } 35 | 36 | OtherClient.prototype.setColor = function (color) { 37 | this.color = color; 38 | }; 39 | 40 | OtherClient.prototype.updateCursor = function (cursor) { 41 | this.removeCursor(); 42 | this.cursor = cursor; 43 | this.mark = this.editorAdapter.setOtherCursor( 44 | cursor, 45 | this.color, 46 | this.id 47 | ); 48 | }; 49 | 50 | OtherClient.prototype.removeCursor = function () { 51 | if (this.mark) { this.mark.clear(); } 52 | }; 53 | 54 | function EditorClient (serverAdapter, editorAdapter) { 55 | Client.call(this); 56 | this.serverAdapter = serverAdapter; 57 | this.editorAdapter = editorAdapter; 58 | this.undoManager = new UndoManager(); 59 | 60 | this.clients = { }; 61 | 62 | var self = this; 63 | 64 | this.editorAdapter.registerCallbacks({ 65 | change: function (operation, inverse) { self.onChange(operation, inverse); }, 66 | cursorActivity: function () { self.onCursorActivity(); }, 67 | blur: function () { self.onBlur(); }, 68 | focus: function () { self.onFocus(); } 69 | }); 70 | this.editorAdapter.registerUndo(function () { self.undo(); }); 71 | this.editorAdapter.registerRedo(function () { self.redo(); }); 72 | 73 | this.serverAdapter.registerCallbacks({ 74 | ack: function () { 75 | self.serverAck(); 76 | if (self.focused && self.state instanceof Client.Synchronized) { 77 | self.updateCursor(); 78 | self.sendCursor(self.cursor); 79 | } 80 | self.emitStatus(); 81 | }, 82 | retry: function() { self.serverRetry(); }, 83 | operation: function (operation) { 84 | self.applyServer(operation); 85 | }, 86 | cursor: function (clientId, cursor, color) { 87 | if (self.serverAdapter.userId_ === clientId || 88 | !(self.state instanceof Client.Synchronized)) { 89 | return; 90 | } 91 | var client = self.getClientObject(clientId); 92 | if (cursor) { 93 | if (color) client.setColor(color); 94 | client.updateCursor(Cursor.fromJSON(cursor)); 95 | } else { 96 | client.removeCursor(); 97 | } 98 | } 99 | }); 100 | } 101 | 102 | inherit(EditorClient, Client); 103 | 104 | EditorClient.prototype.getClientObject = function (clientId) { 105 | var client = this.clients[clientId]; 106 | if (client) { return client; } 107 | return this.clients[clientId] = new OtherClient( 108 | clientId, 109 | this.editorAdapter 110 | ); 111 | }; 112 | 113 | EditorClient.prototype.applyUnredo = function (operation) { 114 | this.undoManager.add(this.editorAdapter.invertOperation(operation)); 115 | this.editorAdapter.applyOperation(operation.wrapped); 116 | this.cursor = operation.meta.cursorAfter; 117 | if (this.cursor) 118 | this.editorAdapter.setCursor(this.cursor); 119 | this.applyClient(operation.wrapped); 120 | }; 121 | 122 | EditorClient.prototype.undo = function () { 123 | var self = this; 124 | if (!this.undoManager.canUndo()) { return; } 125 | this.undoManager.performUndo(function (o) { self.applyUnredo(o); }); 126 | }; 127 | 128 | EditorClient.prototype.redo = function () { 129 | var self = this; 130 | if (!this.undoManager.canRedo()) { return; } 131 | this.undoManager.performRedo(function (o) { self.applyUnredo(o); }); 132 | }; 133 | 134 | EditorClient.prototype.onChange = function (textOperation, inverse) { 135 | var cursorBefore = this.cursor; 136 | this.updateCursor(); 137 | 138 | var compose = this.undoManager.undoStack.length > 0 && 139 | inverse.shouldBeComposedWithInverted(last(this.undoManager.undoStack).wrapped); 140 | var inverseMeta = new SelfMeta(this.cursor, cursorBefore); 141 | this.undoManager.add(new WrappedOperation(inverse, inverseMeta), compose); 142 | this.applyClient(textOperation); 143 | }; 144 | 145 | EditorClient.prototype.updateCursor = function () { 146 | this.cursor = this.editorAdapter.getCursor(); 147 | }; 148 | 149 | EditorClient.prototype.onCursorActivity = function () { 150 | var oldCursor = this.cursor; 151 | this.updateCursor(); 152 | if (!this.focused || oldCursor && this.cursor.equals(oldCursor)) { return; } 153 | this.sendCursor(this.cursor); 154 | }; 155 | 156 | EditorClient.prototype.onBlur = function () { 157 | this.cursor = null; 158 | this.sendCursor(null); 159 | this.focused = false; 160 | }; 161 | 162 | EditorClient.prototype.onFocus = function () { 163 | this.focused = true; 164 | this.onCursorActivity(); 165 | }; 166 | 167 | EditorClient.prototype.sendCursor = function (cursor) { 168 | if (this.state instanceof Client.AwaitingWithBuffer) { return; } 169 | this.serverAdapter.sendCursor(cursor); 170 | }; 171 | 172 | EditorClient.prototype.sendOperation = function (operation) { 173 | this.serverAdapter.sendOperation(operation); 174 | this.emitStatus(); 175 | }; 176 | 177 | EditorClient.prototype.applyOperation = function (operation) { 178 | this.editorAdapter.applyOperation(operation); 179 | this.updateCursor(); 180 | this.undoManager.transform(new WrappedOperation(operation, null)); 181 | }; 182 | 183 | EditorClient.prototype.emitStatus = function() { 184 | var self = this; 185 | setTimeout(function() { 186 | self.trigger('synced', self.state instanceof Client.Synchronized); 187 | }, 0); 188 | }; 189 | 190 | // Set Const.prototype.__proto__ to Super.prototype 191 | function inherit (Const, Super) { 192 | function F () {} 193 | F.prototype = Super.prototype; 194 | Const.prototype = new F(); 195 | Const.prototype.constructor = Const; 196 | } 197 | 198 | function last (arr) { return arr[arr.length - 1]; } 199 | 200 | return EditorClient; 201 | }()); 202 | 203 | firepad.utils.makeEventEmitter(firepad.EditorClient, ['synced']); 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Firepad [![Actions Status][gh-actions-badge]][gh-actions] [![Coverage Status](https://img.shields.io/coveralls/FirebaseExtended/firepad.svg?branch=master&style=flat)](https://coveralls.io/r/FirebaseExtended/firepad) [![Version](https://badge.fury.io/gh/firebase%2Ffirepad.svg)](http://badge.fury.io/gh/firebase%2Ffirepad) 2 | 3 | [Firepad](http://www.firepad.io/) is an open-source, collaborative code and text editor. It is 4 | designed to be embedded inside larger web applications. 5 | 6 | Join our [Firebase Google Group](https://groups.google.com/forum/#!forum/firebase-talk) to ask 7 | questions, request features, or share your Firepad apps with the community. 8 | 9 | ## Status 10 | 11 | ![Status: Frozen](https://img.shields.io/badge/Status-Frozen-yellow) 12 | 13 | This repository is no longer under active development. No new features will be added and issues are not actively triaged. Pull Requests which fix bugs are welcome and will be reviewed on a best-effort basis. 14 | 15 | If you maintain a fork of this repository that you believe is healthier than the official version, we may consider recommending your fork. Please open a Pull Request if you believe that is the case. 16 | 17 | 18 | ## Table of Contents 19 | 20 | * [Getting Started With Firebase](#getting-started-with-firebase) 21 | * [Live Demo](#live-demo) 22 | * [Downloading Firepad](#downloading-firepad) 23 | * [Documentation](#documentation) 24 | * [Examples](#examples) 25 | * [Contributing](#contributing) 26 | * [Database Structure](#database-structure) 27 | * [Repo Structure](#repo-structure) 28 | 29 | 30 | ## Getting Started With Firebase 31 | 32 | Firepad requires [Firebase](https://firebase.google.com/) in order to sync and store data. Firebase 33 | is a suite of integrated products designed to help you develop your app, grow your user base, and 34 | earn money. You can [sign up here for a free account](https://console.firebase.google.com/). 35 | 36 | 37 | ## Live Demo 38 | 39 | Visit [firepad.io](http://demo.firepad.io/) to see a live demo of Firepad in rich text mode, or the 40 | [examples page](http://www.firepad.io/examples/) to see it setup for collaborative code editing. 41 | 42 | [![a screenshot of demo.firepad.io including a picture of two cats and a discussion about fonts](screenshot.png)](http://demo.firepad.io/) 43 | 44 | 45 | ## Downloading Firepad 46 | 47 | Firepad uses [Firebase](https://firebase.google.com) as a backend, so it requires no server-side 48 | code. It can be added to any web app by including a few JavaScript files: 49 | 50 | ```HTML 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ``` 66 | 67 | Then, you need to initialize the Firebase SDK and Firepad: 68 | 69 | ```HTML 70 | 71 |
72 | 91 | 92 | ``` 93 | 94 | ## Documentation 95 | 96 | Firepad supports rich text editing with [CodeMirror](http://codemirror.net/) and code editing via 97 | [Ace](http://ace.c9.io/). Check out the detailed setup instructions at [firepad.io/docs](http://www.firepad.io/docs). 98 | 99 | 100 | ## Examples 101 | 102 | You can find some Firepad examples [here](examples/README.md). 103 | 104 | 105 | ## Contributing 106 | 107 | If you'd like to contribute to Firepad, please first read through our [contribution 108 | guidelines](.github/CONTRIBUTING.md). Local setup instructions are available [here](.github/CONTRIBUTING.md#local-setup). 109 | 110 | ## Database Structure 111 | How is the data structured in Firebase? 112 | 113 | * `/` - A unique hash generated when pushing a new item to Firebase. 114 | * `users/` 115 | * `/` - A unique hash that identifies each user. 116 | * `cursor` - The current location of the user's cursor. 117 | * `color` - The color of the user's cursor. 118 | * `history/` - The sequence of revisions that are automatically made as the document is edited. 119 | * `/` - A unique id that ranges from 'A0' onwards. 120 | * `a` - The user id that made the revision. 121 | * `o/` - Array of operations (eg TextOperation objects) that represent document changes. 122 | * `t` - Timestamp in milliseconds determined by the Firebase servers. 123 | * `checkpoint/` - Snapshot automatically created every 100 revisions. 124 | * `a` - The user id that triggered the checkpoint. 125 | * `id` - The latest revision at the time the checkpoint was taken. 126 | * `o/` - A representation of the document state at that time that includes styling and plaintext. 127 | 128 | 129 | ## Repo Structure 130 | 131 | Here are some highlights of the directory structure and notable source files: 132 | 133 | * `dist/` - output directory for all files generated by grunt (`firepad.js`, `firepad.min.js`, `firepad.css`, `firepad.eot`). 134 | * `examples/` - examples of embedding Firepad. 135 | * `font/` - icon font used for rich text toolbar. 136 | * `lib/` 137 | * `firepad.js` - Entry point for Firepad. 138 | * `text-operation.js`, `client.js` - Heart of the Operation Transformation implementation. Based on 139 | [ot.js](https://github.com/Operational-Transformation/ot.js/) but extended to allow arbitrary 140 | attributes on text (for representing rich-text). 141 | * `annotation-list.js` - A data model for representing annotations on text (i.e. spans of text with a particular 142 | set of attributes). 143 | * `rich-text-codemirror.js` - Uses `AnnotationList` to track annotations on the text and maintain the appropriate 144 | set of markers on a CodeMirror instance. 145 | * `firebase-adapter.js` - Handles integration with Firebase (appending operations, triggering retries, 146 | presence, etc.). 147 | * `test/` - Jasmine tests for Firepad (many of these were borrowed from ot.js). 148 | 149 | [gh-actions]: https://github.com/FirebaseExtended/firepad/actions 150 | [gh-actions-badge]: https://github.com/FirebaseExtended/firepad/workflows/CI%20Tests/badge.svg 151 | -------------------------------------------------------------------------------- /lib/ace-adapter.coffee: -------------------------------------------------------------------------------- 1 | firepad = {} unless firepad? 2 | 3 | class firepad.ACEAdapter 4 | ignoreChanges: false 5 | 6 | constructor: (aceInstance) -> 7 | @ace = aceInstance 8 | @aceSession = @ace.getSession() 9 | @aceDoc = @aceSession.getDocument() 10 | @aceDoc.setNewLineMode 'unix' 11 | @grabDocumentState() 12 | @ace.on 'change', @onChange 13 | @ace.on 'blur', @onBlur 14 | @ace.on 'focus', @onFocus 15 | @aceSession.selection.on 'changeCursor', @onCursorActivity 16 | @aceRange ?= (ace.require ? require)("ace/range").Range 17 | 18 | grabDocumentState: -> 19 | @lastDocLines = @aceDoc.getAllLines() 20 | @lastCursorRange = @aceSession.selection.getRange() 21 | 22 | # Removes all event listeners from the ACE editor instance 23 | detach: -> 24 | @ace.removeListener 'change', @onChange 25 | @ace.removeListener 'blur', @onBlur 26 | @ace.removeListener 'focus', @onFocus 27 | @aceSession.selection.removeListener 'changeCursor', @onCursorActivity 28 | 29 | onChange: (change) => 30 | unless @ignoreChanges 31 | pair = @operationFromACEChange change 32 | @trigger 'change', pair... 33 | @grabDocumentState() 34 | 35 | onBlur: => 36 | @trigger 'blur' if @ace.selection.isEmpty() 37 | 38 | onFocus: => 39 | @trigger 'focus' 40 | 41 | onCursorActivity: => 42 | setTimeout => 43 | @trigger 'cursorActivity' 44 | , 0 45 | 46 | # Converts an ACE change object into a TextOperation and its inverse 47 | # and returns them as a two-element array. 48 | operationFromACEChange: (change) -> 49 | if change.data 50 | # Ace < 1.2.0 51 | delta = change.data 52 | if delta.action in ['insertLines', 'removeLines'] 53 | text = delta.lines.join('\n') + '\n' 54 | action = delta.action.replace 'Lines', '' 55 | else 56 | text = delta.text.replace(@aceDoc.getNewLineCharacter(), '\n') 57 | action = delta.action.replace 'Text', '' 58 | start = @indexFromPos delta.range.start 59 | else 60 | # Ace 1.2.0+ 61 | text = change.lines.join('\n') 62 | start = @indexFromPos change.start 63 | 64 | restLength = @lastDocLines.join('\n').length - start 65 | restLength -= text.length if change.action is 'remove' 66 | insert_op = new firepad.TextOperation().retain(start).insert(text).retain(restLength) 67 | delete_op = new firepad.TextOperation().retain(start).delete(text).retain(restLength) 68 | if change.action is 'remove' 69 | [delete_op, insert_op] 70 | else 71 | [insert_op, delete_op] 72 | 73 | # Apply an operation to an ACE instance. 74 | applyOperationToACE: (operation) -> 75 | index = 0 76 | for op in operation.ops 77 | if op.isRetain() 78 | index += op.chars 79 | else if op.isInsert() 80 | @aceDoc.insert @posFromIndex(index), op.text 81 | index += op.text.length 82 | else if op.isDelete() 83 | from = @posFromIndex index 84 | to = @posFromIndex index + op.chars 85 | range = @aceRange.fromPoints from, to 86 | @aceDoc.remove range 87 | @grabDocumentState() 88 | 89 | posFromIndex: (index) -> 90 | for line, row in @aceDoc.$lines 91 | break if index <= line.length 92 | index -= line.length + 1 93 | row: row, column: index 94 | 95 | indexFromPos: (pos, lines) -> 96 | lines ?= @lastDocLines 97 | index = 0 98 | for i in [0 ... pos.row] 99 | index += @lastDocLines[i].length + 1 100 | index += pos.column 101 | 102 | getValue: -> 103 | @aceDoc.getValue() 104 | 105 | getCursor: -> 106 | try 107 | start = @indexFromPos @aceSession.selection.getRange().start, @aceDoc.$lines 108 | end = @indexFromPos @aceSession.selection.getRange().end, @aceDoc.$lines 109 | catch e 110 | # If the new range doesn't work (sometimes with setValue), we'll use the old range 111 | try 112 | start = @indexFromPos @lastCursorRange.start 113 | end = @indexFromPos @lastCursorRange.end 114 | catch e2 115 | console.log "Couldn't figure out the cursor range:", e2, "-- setting it to 0:0." 116 | [start, end] = [0, 0] 117 | if start > end 118 | [start, end] = [end, start] 119 | new firepad.Cursor start, end 120 | 121 | setCursor: (cursor) -> 122 | start = @posFromIndex cursor.position 123 | end = @posFromIndex cursor.selectionEnd 124 | if cursor.position > cursor.selectionEnd 125 | [start, end] = [end, start] 126 | @aceSession.selection.setSelectionRange new @aceRange(start.row, start.column, end.row, end.column) 127 | 128 | setOtherCursor: (cursor, color, clientId) -> 129 | @otherCursors ?= {} 130 | cursorRange = @otherCursors[clientId] 131 | if cursorRange 132 | cursorRange.start.detach() 133 | cursorRange.end.detach() 134 | @aceSession.removeMarker cursorRange.id 135 | start = @posFromIndex cursor.position 136 | end = @posFromIndex cursor.selectionEnd 137 | if cursor.selectionEnd < cursor.position 138 | [start, end] = [end, start] 139 | clazz = "other-client-selection-#{color.replace '#', ''}" 140 | justCursor = cursor.position is cursor.selectionEnd 141 | clazz = clazz.replace 'selection', 'cursor' if justCursor 142 | css = """.#{clazz} { 143 | position: absolute; 144 | background-color: #{if justCursor then 'transparent' else color}; 145 | border-left: 2px solid #{color}; 146 | }""" 147 | @addStyleRule css 148 | @otherCursors[clientId] = cursorRange = new @aceRange start.row, start.column, end.row, end.column 149 | 150 | # Hack this specific range to, when clipped, return an empty range that 151 | # pretends to not be empty. This lets us draw markers at the ends of lines. 152 | # This might be brittle in the future. 153 | self = this 154 | cursorRange.clipRows = -> 155 | range = self.aceRange::clipRows.apply this, arguments 156 | range.isEmpty = -> false 157 | range 158 | 159 | cursorRange.start = @aceDoc.createAnchor cursorRange.start 160 | cursorRange.end = @aceDoc.createAnchor cursorRange.end 161 | cursorRange.id = @aceSession.addMarker cursorRange, clazz, "text" 162 | # Return something with a clear method to mimic expected API from CodeMirror 163 | return clear: => 164 | cursorRange.start.detach() 165 | cursorRange.end.detach() 166 | @aceSession.removeMarker cursorRange.id 167 | 168 | addStyleRule: (css) -> 169 | return unless document? 170 | unless @addedStyleRules 171 | @addedStyleRules = {} 172 | styleElement = document.createElement 'style' 173 | document.documentElement.getElementsByTagName('head')[0].appendChild styleElement 174 | @addedStyleSheet = styleElement.sheet 175 | return if @addedStyleRules[css] 176 | @addedStyleRules[css] = true 177 | @addedStyleSheet.insertRule css, 0 178 | 179 | registerCallbacks: (@callbacks) -> 180 | 181 | trigger: (event, args...) -> 182 | @callbacks?[event]?.apply @, args 183 | 184 | applyOperation: (operation) -> 185 | @ignoreChanges = true unless operation.isNoop() 186 | @applyOperationToACE operation 187 | @ignoreChanges = false 188 | 189 | registerUndo: (undoFn) -> 190 | @ace.undo = undoFn 191 | 192 | registerRedo: (redoFn) -> 193 | @ace.redo = redoFn 194 | 195 | invertOperation: (operation) -> 196 | # TODO: Optimize to avoid copying entire text? 197 | operation.invert @getValue() -------------------------------------------------------------------------------- /lib/serialize-html.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | 3 | /** 4 | * Helper to turn Firebase contents into HMTL. 5 | * Takes a doc and an entity manager 6 | */ 7 | firepad.SerializeHtml = (function () { 8 | 9 | var utils = firepad.utils; 10 | var ATTR = firepad.AttributeConstants; 11 | var LIST_TYPE = firepad.LineFormatting.LIST_TYPE; 12 | var TODO_STYLE = '\n'; 13 | 14 | function open(listType) { 15 | return (listType === LIST_TYPE.ORDERED) ? '
    ' : 16 | (listType === LIST_TYPE.UNORDERED) ? '
      ' : 17 | '
        '; 18 | } 19 | 20 | function close(listType) { 21 | return (listType === LIST_TYPE.ORDERED) ? '
' : ''; 22 | } 23 | 24 | function compatibleListType(l1, l2) { 25 | return (l1 === l2) || 26 | (l1 === LIST_TYPE.TODO && l2 === LIST_TYPE.TODOCHECKED) || 27 | (l1 === LIST_TYPE.TODOCHECKED && l2 === LIST_TYPE.TODO); 28 | } 29 | 30 | function textToHtml(text) { 31 | return text.replace(/&/g, '&') 32 | .replace(/"/g, '"') 33 | .replace(/'/g, ''') 34 | .replace(//g, '>') 36 | .replace(/\u00a0/g, ' ') 37 | } 38 | 39 | function serializeHtml(doc, entityManager) { 40 | var html = ''; 41 | var newLine = true; 42 | var listTypeStack = []; 43 | var inListItem = false; 44 | var firstLine = true; 45 | var emptyLine = true; 46 | var i = 0, op = doc.ops[i]; 47 | var usesTodo = false; 48 | while(op) { 49 | utils.assert(op.isInsert()); 50 | var attrs = op.attributes; 51 | 52 | if (newLine) { 53 | newLine = false; 54 | 55 | var indent = 0, listType = null, lineAlign = 'left'; 56 | if (ATTR.LINE_SENTINEL in attrs) { 57 | indent = attrs[ATTR.LINE_INDENT] || 0; 58 | listType = attrs[ATTR.LIST_TYPE] || null; 59 | lineAlign = attrs[ATTR.LINE_ALIGN] || 'left'; 60 | } 61 | if (listType) { 62 | indent = indent || 1; // lists are automatically indented at least 1. 63 | } 64 | 65 | if (inListItem) { 66 | html += ''; 67 | inListItem = false; 68 | } else if (!firstLine) { 69 | if (emptyLine) { 70 | html += '
'; 71 | } 72 | html += '
'; 73 | } 74 | firstLine = false; 75 | 76 | // Close any extra lists. 77 | utils.assert(indent >= 0, "Indent must not be negative."); 78 | while (listTypeStack.length > indent || 79 | (indent === listTypeStack.length && listType !== null && !compatibleListType(listType, listTypeStack[listTypeStack.length - 1]))) { 80 | html += close(listTypeStack.pop()); 81 | } 82 | 83 | // Open any needed lists. 84 | while (listTypeStack.length < indent) { 85 | var toOpen = listType || LIST_TYPE.UNORDERED; // default to unordered lists for indenting non-list-item lines. 86 | usesTodo = listType == LIST_TYPE.TODO || listType == LIST_TYPE.TODOCHECKED || usesTodo; 87 | html += open(toOpen); 88 | listTypeStack.push(toOpen); 89 | } 90 | 91 | var style = (lineAlign !== 'left') ? ' style="text-align:' + lineAlign + '"': ''; 92 | if (listType) { 93 | var clazz = ''; 94 | switch (listType) 95 | { 96 | case LIST_TYPE.TODOCHECKED: 97 | clazz = ' class="firepad-checked"'; 98 | break; 99 | case LIST_TYPE.TODO: 100 | clazz = ' class="firepad-unchecked"'; 101 | break; 102 | } 103 | html += ""; 104 | inListItem = true; 105 | } else { 106 | // start line div. 107 | html += ''; 108 | } 109 | emptyLine = true; 110 | } 111 | 112 | if (ATTR.LINE_SENTINEL in attrs) { 113 | op = doc.ops[++i]; 114 | continue; 115 | } 116 | 117 | if (ATTR.ENTITY_SENTINEL in attrs) { 118 | for(var j = 0; j < op.text.length; j++) { 119 | var entity = firepad.Entity.fromAttributes(attrs); 120 | var element = entityManager.exportToElement(entity); 121 | html += element.outerHTML; 122 | } 123 | 124 | op = doc.ops[++i]; 125 | continue; 126 | } 127 | 128 | var prefix = '', suffix = ''; 129 | for(var attr in attrs) { 130 | var value = attrs[attr]; 131 | var start, end; 132 | if (attr === ATTR.BOLD || attr === ATTR.ITALIC || attr === ATTR.UNDERLINE || attr === ATTR.STRIKE) { 133 | utils.assert(value === true); 134 | start = end = attr; 135 | } else if (attr === ATTR.FONT_SIZE) { 136 | start = 'span style="font-size: ' + value; 137 | start += (typeof value !== "string" || value.indexOf("px", value.length - 2) === -1) ? 'px"' : '"'; 138 | end = 'span'; 139 | } else if (attr === ATTR.FONT) { 140 | start = 'span style="font-family: ' + value + '"'; 141 | end = 'span'; 142 | } else if (attr === ATTR.COLOR) { 143 | start = 'span style="color: ' + value + '"'; 144 | end = 'span'; 145 | } else if (attr === ATTR.BACKGROUND_COLOR) { 146 | start = 'span style="background-color: ' + value + '"'; 147 | end = 'span'; 148 | } 149 | else { 150 | utils.log(false, "Encountered unknown attribute while rendering html: " + attr); 151 | } 152 | if (start) prefix += '<' + start + '>'; 153 | if (end) suffix = '' + suffix; 154 | } 155 | 156 | var text = op.text; 157 | var newLineIndex = text.indexOf('\n'); 158 | if (newLineIndex >= 0) { 159 | newLine = true; 160 | if (newLineIndex < text.length - 1) { 161 | // split op. 162 | op = new firepad.TextOp('insert', text.substr(newLineIndex+1), attrs); 163 | } else { 164 | op = doc.ops[++i]; 165 | } 166 | text = text.substr(0, newLineIndex); 167 | } else { 168 | op = doc.ops[++i]; 169 | } 170 | 171 | // Replace leading, trailing, and consecutive spaces with nbsp's to make sure they're preserved. 172 | text = text.replace(/ +/g, function(str) { 173 | return new Array(str.length + 1).join('\u00a0'); 174 | }).replace(/^ /, '\u00a0').replace(/ $/, '\u00a0'); 175 | if (text.length > 0) { 176 | emptyLine = false; 177 | } 178 | 179 | html += prefix + textToHtml(text) + suffix; 180 | } 181 | 182 | if (inListItem) { 183 | html += ''; 184 | } else if (!firstLine) { 185 | if (emptyLine) { 186 | html += ' '; 187 | } 188 | html += ''; 189 | } 190 | 191 | // Close any extra lists. 192 | while (listTypeStack.length > 0) { 193 | html += close(listTypeStack.pop()); 194 | } 195 | 196 | if (usesTodo) { 197 | html = TODO_STYLE + html; 198 | } 199 | 200 | return html; 201 | } 202 | 203 | return serializeHtml; 204 | })(); 205 | -------------------------------------------------------------------------------- /examples/firepad-userlist.js: -------------------------------------------------------------------------------- 1 | var FirepadUserList = (function() { 2 | function FirepadUserList(ref, place, userId, displayName) { 3 | if (!(this instanceof FirepadUserList)) { 4 | return new FirepadUserList(ref, place, userId, displayName); 5 | } 6 | 7 | this.ref_ = ref; 8 | this.userId_ = userId; 9 | this.place_ = place; 10 | this.firebaseCallbacks_ = []; 11 | 12 | var self = this; 13 | this.hasName_ = !!displayName; 14 | this.displayName_ = displayName || 'Guest ' + Math.floor(Math.random() * 1000); 15 | this.firebaseOn_(ref.root.child('.info/connected'), 'value', function(s) { 16 | if (s.val() === true && self.displayName_) { 17 | var nameRef = ref.child(self.userId_).child('name'); 18 | nameRef.onDisconnect().remove(); 19 | nameRef.set(self.displayName_); 20 | } 21 | }); 22 | 23 | this.userList_ = this.makeUserList_() 24 | place.appendChild(this.userList_); 25 | } 26 | 27 | // This is the primary "constructor" for symmetry with Firepad. 28 | FirepadUserList.fromDiv = FirepadUserList; 29 | 30 | FirepadUserList.prototype.dispose = function() { 31 | this.removeFirebaseCallbacks_(); 32 | this.ref_.child(this.userId_).child('name').remove(); 33 | 34 | this.place_.removeChild(this.userList_); 35 | }; 36 | 37 | FirepadUserList.prototype.makeUserList_ = function() { 38 | return elt('div', [ 39 | this.makeHeading_(), 40 | elt('div', [ 41 | this.makeUserEntryForSelf_(), 42 | this.makeUserEntriesForOthers_() 43 | ], {'class': 'firepad-userlist-users' }) 44 | ], {'class': 'firepad-userlist' }); 45 | }; 46 | 47 | FirepadUserList.prototype.makeHeading_ = function() { 48 | var counterSpan = elt('span', '0'); 49 | this.firebaseOn_(this.ref_, 'value', function(usersSnapshot) { 50 | setTextContent(counterSpan, "" + usersSnapshot.numChildren()); 51 | }); 52 | 53 | return elt('div', [ 54 | elt('span', 'ONLINE ('), 55 | counterSpan, 56 | elt('span', ')') 57 | ], { 'class': 'firepad-userlist-heading' }); 58 | }; 59 | 60 | FirepadUserList.prototype.makeUserEntryForSelf_ = function() { 61 | var myUserRef = this.ref_.child(this.userId_); 62 | 63 | var colorDiv = elt('div', null, { 'class': 'firepad-userlist-color-indicator' }); 64 | this.firebaseOn_(myUserRef.child('color'), 'value', function(colorSnapshot) { 65 | var color = colorSnapshot.val(); 66 | if (isValidColor(color)) { 67 | colorDiv.style.backgroundColor = color; 68 | } 69 | }); 70 | 71 | var nameInput = elt('input', null, { type: 'text', 'class': 'firepad-userlist-name-input'} ); 72 | nameInput.value = this.displayName_; 73 | 74 | var nameHint = elt('div', 'ENTER YOUR NAME', { 'class': 'firepad-userlist-name-hint'} ); 75 | if (this.hasName_) nameHint.style.display = 'none'; 76 | 77 | // Update Firebase when name changes. 78 | var self = this; 79 | on(nameInput, 'change', function(e) { 80 | var name = nameInput.value || "Guest " + Math.floor(Math.random() * 1000); 81 | myUserRef.child('name').onDisconnect().remove(); 82 | myUserRef.child('name').set(name); 83 | nameHint.style.display = 'none'; 84 | nameInput.blur(); 85 | self.displayName_ = name; 86 | stopEvent(e); 87 | }); 88 | 89 | var nameDiv = elt('div', [nameInput, nameHint]); 90 | 91 | return elt('div', [ colorDiv, nameDiv ], { 92 | 'class': 'firepad-userlist-user ' + 'firepad-user-' + this.userId_ 93 | }); 94 | }; 95 | 96 | FirepadUserList.prototype.makeUserEntriesForOthers_ = function() { 97 | var self = this; 98 | var userList = elt('div'); 99 | var userId2Element = { }; 100 | 101 | function updateChild(userSnapshot, prevChildName) { 102 | var userId = userSnapshot.key; 103 | var div = userId2Element[userId]; 104 | if (div) { 105 | userList.removeChild(div); 106 | delete userId2Element[userId]; 107 | } 108 | var name = userSnapshot.child('name').val(); 109 | if (typeof name !== 'string') { name = 'Guest'; } 110 | name = name.substring(0, 20); 111 | 112 | var color = userSnapshot.child('color').val(); 113 | if (!isValidColor(color)) { 114 | color = "#ffb" 115 | } 116 | 117 | var colorDiv = elt('div', null, { 'class': 'firepad-userlist-color-indicator' }); 118 | colorDiv.style.backgroundColor = color; 119 | 120 | var nameDiv = elt('div', name || 'Guest', { 'class': 'firepad-userlist-name' }); 121 | 122 | var userDiv = elt('div', [ colorDiv, nameDiv ], { 123 | 'class': 'firepad-userlist-user ' + 'firepad-user-' + userId 124 | }); 125 | userId2Element[userId] = userDiv; 126 | 127 | if (userId === self.userId_) { 128 | // HACK: We go ahead and insert ourself in the DOM, so we can easily order other users against it. 129 | // But don't show it. 130 | userDiv.style.display = 'none'; 131 | } 132 | 133 | var nextElement = prevChildName ? userId2Element[prevChildName].nextSibling : userList.firstChild; 134 | userList.insertBefore(userDiv, nextElement); 135 | } 136 | 137 | this.firebaseOn_(this.ref_, 'child_added', updateChild); 138 | this.firebaseOn_(this.ref_, 'child_changed', updateChild); 139 | this.firebaseOn_(this.ref_, 'child_moved', updateChild); 140 | this.firebaseOn_(this.ref_, 'child_removed', function(removedSnapshot) { 141 | var userId = removedSnapshot.key; 142 | var div = userId2Element[userId]; 143 | if (div) { 144 | userList.removeChild(div); 145 | delete userId2Element[userId]; 146 | } 147 | }); 148 | 149 | return userList; 150 | }; 151 | 152 | FirepadUserList.prototype.firebaseOn_ = function(ref, eventType, callback, context) { 153 | this.firebaseCallbacks_.push({ref: ref, eventType: eventType, callback: callback, context: context }); 154 | ref.on(eventType, callback, context); 155 | return callback; 156 | }; 157 | 158 | FirepadUserList.prototype.firebaseOff_ = function(ref, eventType, callback, context) { 159 | ref.off(eventType, callback, context); 160 | for(var i = 0; i < this.firebaseCallbacks_.length; i++) { 161 | var l = this.firebaseCallbacks_[i]; 162 | if (l.ref === ref && l.eventType === eventType && l.callback === callback && l.context === context) { 163 | this.firebaseCallbacks_.splice(i, 1); 164 | break; 165 | } 166 | } 167 | }; 168 | 169 | FirepadUserList.prototype.removeFirebaseCallbacks_ = function() { 170 | for(var i = 0; i < this.firebaseCallbacks_.length; i++) { 171 | var l = this.firebaseCallbacks_[i]; 172 | l.ref.off(l.eventType, l.callback, l.context); 173 | } 174 | this.firebaseCallbacks_ = []; 175 | }; 176 | 177 | /** Assorted helpers */ 178 | 179 | function isValidColor(color) { 180 | return typeof color === 'string' && 181 | (color.match(/^#[a-fA-F0-9]{3,6}$/) || color == 'transparent'); 182 | } 183 | 184 | 185 | /** DOM helpers */ 186 | function elt(tag, content, attrs) { 187 | var e = document.createElement(tag); 188 | if (typeof content === "string") { 189 | setTextContent(e, content); 190 | } else if (content) { 191 | for (var i = 0; i < content.length; ++i) { e.appendChild(content[i]); } 192 | } 193 | for(var attr in (attrs || { })) { 194 | e.setAttribute(attr, attrs[attr]); 195 | } 196 | return e; 197 | } 198 | 199 | function setTextContent(e, str) { 200 | e.innerHTML = ""; 201 | e.appendChild(document.createTextNode(str)); 202 | } 203 | 204 | function on(emitter, type, f) { 205 | if (emitter.addEventListener) { 206 | emitter.addEventListener(type, f, false); 207 | } else if (emitter.attachEvent) { 208 | emitter.attachEvent("on" + type, f); 209 | } 210 | } 211 | 212 | function off(emitter, type, f) { 213 | if (emitter.removeEventListener) { 214 | emitter.removeEventListener(type, f, false); 215 | } else if (emitter.detachEvent) { 216 | emitter.detachEvent("on" + type, f); 217 | } 218 | } 219 | 220 | function preventDefault(e) { 221 | if (e.preventDefault) { 222 | e.preventDefault(); 223 | } else { 224 | e.returnValue = false; 225 | } 226 | } 227 | 228 | function stopPropagation(e) { 229 | if (e.stopPropagation) { 230 | e.stopPropagation(); 231 | } else { 232 | e.cancelBubble = true; 233 | } 234 | } 235 | 236 | function stopEvent(e) { 237 | preventDefault(e); 238 | stopPropagation(e); 239 | } 240 | 241 | return FirepadUserList; 242 | })(); 243 | -------------------------------------------------------------------------------- /font/firepad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | This is a custom SVG font generated by IcoMoon. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 35 | 44 | 46 | 48 | 50 | 52 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /font/firepad.dev.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | This is a custom SVG font generated by IcoMoon. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 35 | 44 | 46 | 48 | 50 | 52 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /test/specs/parse-html.spec.js: -------------------------------------------------------------------------------- 1 | describe('Parse HTML Tests', function() { 2 | var Line = firepad.Line; 3 | var Text = firepad.Text; 4 | var lf = firepad.LineFormatting(); 5 | var tf = firepad.Formatting(); 6 | var parse = firepad.ParseHtml; 7 | var LIST_TYPE = firepad.LineFormatting.LIST_TYPE; 8 | 9 | it('Empty HTML', function() { 10 | parseTest('', []); 11 | }); 12 | 13 | it('
', function() { 14 | parseTest('
', [ 15 | Line([], lf) 16 | ]); 17 | }); 18 | 19 | it('div with style', function() { 20 | parseTest('
Test
', [ 21 | Line([Text('Test', tf.bold(true))], lf) 22 | ]); 23 | }); 24 | 25 | it("Handle newlines and extra spaces in html.", function() { 26 | parseTest('Foo\nBar\nBaz Blah', [ 27 | Line([Text('Foo Bar Baz Blah')], lf) 28 | ]); 29 | }); 30 | 31 | function styleTest(style, textFormatting) { 32 | var html = '
Test
'; 33 | parseTest(html, [ 34 | Line([Text('Test', textFormatting)], lf) 35 | ]); 36 | } 37 | 38 | it('Supported styles', function() { 39 | styleTest('', tf); 40 | styleTest('font-weight: bold', tf.bold(true)); 41 | styleTest('text-decoration: underline', tf.underline(true)); 42 | styleTest('font-style: italic', tf.italic(true)); 43 | styleTest('font-style: oblique', tf.italic(true)); 44 | styleTest('color: green', tf.color('green')); 45 | styleTest('background-color: green', tf.backgroundColor('green')); 46 | styleTest('font-family: Times New Roman', tf.font('Times New Roman')); 47 | 48 | styleTest('font-size: xx-small', tf.fontSize('xx-small')); 49 | styleTest('font-size: x-small', tf.fontSize('x-small')); 50 | styleTest('font-size: small', tf.fontSize('small')); 51 | styleTest('font-size: medium', tf.fontSize('medium')); 52 | styleTest('font-size: large', tf.fontSize('large')); 53 | styleTest('font-size: x-large', tf.fontSize('x-large')); 54 | styleTest('font-size: xx-large', tf.fontSize('xx-large')); 55 | styleTest('font-size: 18px', tf.fontSize('18px')); 56 | 57 | // Multiple styles with weird spacing. 58 | styleTest('font-weight:bold ; text-decoration : underline ', tf.bold(true).underline(true)); 59 | }); 60 | 61 | it('Inline tags (b, strong, u, i, em)', function() { 62 | inlineTest('b', tf.bold(true)); 63 | inlineTest('strong', tf.bold(true)); 64 | inlineTest('u', tf.underline(true)); 65 | inlineTest('i', tf.italic(true)); 66 | inlineTest('em', tf.italic(true)); 67 | }); 68 | 69 | function inlineTest(tag, textFormatting) { 70 | var html = '<' + tag + '>Test'; 71 | parseTest(html, [ 72 | Line([Text('Test', textFormatting)], lf) 73 | ]); 74 | } 75 | 76 | it('Supported font tags', function() { 77 | fontTest('color="blue"', tf.color('blue')); 78 | fontTest('face="impact"', tf.font('impact')); 79 | fontTest('size="8"', tf.fontSize(8)); 80 | fontTest('size="8" color="blue" face="impact"', tf.fontSize(8).color('blue').font('impact')); 81 | }); 82 | 83 | function fontTest(attrs, textFormatting) { 84 | var html = 'Test'; 85 | parseTest(html, [ 86 | Line([Text('Test', textFormatting)], lf) 87 | ]); 88 | } 89 | 90 | it('Nested divs', function() { 91 | parseTest('
Foo
bar
Baz
', [ 92 | Line([Text('Foo', tf.color('green').bold(true))], lf), 93 | Line([Text('bar', tf.color('green'))], lf), 94 | Line([Text('Baz', tf.backgroundColor('blue'))], lf) 95 | ]); 96 | }); 97 | 98 | it('Spans with styles', function() { 99 | parseTest('FoobarbazLorem ipsum', [ 100 | Line([Text('Foo', tf.bold(true)), Text('bar', tf.bold(true).color('green')), Text('baz', tf.bold(true)), Text('Lorem ipsum', tf.backgroundColor('blue'))], lf) 101 | ]); 102 | }); 103 | 104 | function entityText(entityType, info) { 105 | var formatting = new firepad.Formatting( 106 | (new firepad.Entity(entityType, info)).toAttributes() 107 | ); 108 | return Text(firepad.sentinelConstants.ENTITY_SENTINEL_CHARACTER, formatting); 109 | } 110 | 111 | it('Images', function() { 112 | var t = Text('Foo', tf); 113 | parseTest('FooFoo', [ 114 | Line([t, entityText('img', { src: 'http://www.google.com/favicon.ico' }), t], lf) 115 | ]); 116 | }); 117 | 118 | it('Unordered list', function() { 119 | var t = Text('Foo', tf); 120 | parseTest('
  • Foo
  • Foo
', [ 121 | Line([t], lf.indent(1).listItem(LIST_TYPE.UNORDERED)), 122 | Line([t], lf.indent(1).listItem(LIST_TYPE.UNORDERED)) 123 | ]); 124 | }); 125 | 126 | it('Ordered list', function() { 127 | var t = Text('Foo', tf); 128 | parseTest('
  1. Foo
  2. Foo
  3. ', [ 129 | Line([t], lf.indent(1).listItem(LIST_TYPE.ORDERED)), 130 | Line([t], lf.indent(1).listItem(LIST_TYPE.ORDERED)) 131 | ]); 132 | }); 133 | 134 | it('Divs listed in list items', function() { 135 | var t = Text('Foo', tf); 136 | parseTest('
    1. Foo
      Foo
    2. Foo
    3. ', [ 137 | Line([t], lf.indent(1).listItem(LIST_TYPE.ORDERED)), 138 | Line([t], lf.indent(1)), // should be indented, but no list item. 139 | Line([t], lf.indent(1).listItem(LIST_TYPE.ORDERED)) 140 | ]); 141 | }); 142 | 143 | it('Complex list (1)', function() { 144 | var t = Text('Foo', tf); 145 | parseTest('
      1. Foo
        1. Foo
        2. Foo
      2. Foo
      ', [ 146 | Line([t], lf.indent(1).listItem(LIST_TYPE.ORDERED)), 147 | Line([t], lf.indent(2).listItem(LIST_TYPE.ORDERED)), 148 | Line([t], lf.indent(2).listItem(LIST_TYPE.ORDERED)), 149 | Line([t], lf.indent(1).listItem(LIST_TYPE.ORDERED)) 150 | ]); 151 | }); 152 | 153 | // same as last, but with no text on each line. 154 | it('Complex list (2)', function() { 155 | var t = Text('Foo', tf); 156 | parseTest('
      ', [ 157 | Line([], lf.indent(1).listItem(LIST_TYPE.ORDERED)), 158 | Line([], lf.indent(2).listItem(LIST_TYPE.ORDERED)), 159 | Line([], lf.indent(2).listItem(LIST_TYPE.ORDERED)), 160 | Line([], lf.indent(1).listItem(LIST_TYPE.ORDERED)) 161 | ]); 162 | }); 163 | 164 | it('Text after list', function() { 165 | parseTest('
      • Hello
      Foo', [ 166 | Line([Text("Hello")], lf.indent(1).listItem(LIST_TYPE.UNORDERED)), 167 | Line([Text("Foo")], lf) 168 | ]); 169 | }); 170 | 171 | it('Todo list support', function() { 172 | var t = Text('Foo', tf); 173 | parseTest('
      • Foo
      • Foo
      ', [ 174 | Line([t], lf.indent(1).listItem(LIST_TYPE.TODO)), 175 | Line([t], lf.indent(1).listItem(LIST_TYPE.TODOCHECKED)) 176 | ]); 177 | }); 178 | 179 | it('
      support (1)', function() { 180 | parseTest('
      foo
      ', [ 181 | Line([Text("foo")], lf.align('center')) 182 | ]); 183 | }); 184 | 185 | it('
      support (2)', function() { 186 | parseTest('
      foo
      bar
      ', [ 187 | Line([Text("foo")], lf.align('center')), 188 | Line([Text("bar")], lf.align('center')) 189 | ]); 190 | }); 191 | 192 | it('
      text-align support (1).', function() { 193 | testAlign('left'); 194 | testAlign('center'); 195 | testAlign('right'); 196 | }); 197 | 198 | function testAlign(align) { 199 | parseTest('
      foo
      bar
      ', [ 200 | Line([Text("foo")], lf.align(align)), 201 | Line([Text("bar")], lf.align(align)) 202 | ]); 203 | } 204 | 205 | function parseTest(html, expLines) { 206 | var actLines = parse(html, new firepad.EntityManager()); 207 | for(var i = 0; i < expLines.length; i++) { 208 | var expLine = dumpLine(expLines[i]); 209 | if (i >= actLines.length) { 210 | throw new Error("Line " + i + ":\n Expected: " + expLine + "\n Actual : "); 211 | } 212 | var actLine = dumpLine(actLines[i]); 213 | if (actLine !== expLine) { 214 | throw new Error("Line " + i + ":\n Expected: " + expLine + "\n Actual : " + actLine); 215 | } 216 | } 217 | if (i < actLines.length) { 218 | throw new Error("Unexpected extra line " + i + ":" + dumpLine(actLines[i])); 219 | } 220 | expect(actLines.length).toEqual(expLines.length); 221 | } 222 | 223 | function dumpLine(line) { 224 | expect(line instanceof Line).toBe(true); 225 | var text = 'Line(['; 226 | for(var i = 0; i < line.textPieces.length; i++) { 227 | if (i !== 0) { 228 | text += ', '; 229 | } 230 | var t = line.textPieces[i]; 231 | text += 'Text("' + t.text + '", ' + dumpObj(t.formatting.attributes) + ')'; 232 | } 233 | text += '], ' + dumpObj(line.formatting.attributes) + ')'; 234 | return text; 235 | } 236 | 237 | // Basically JSON.stringify, except sorts object keys alphabetically so that the output is deterministic. 238 | function dumpObj(obj) { 239 | if (obj === null) { 240 | return 'null'; 241 | } else if (typeof obj === 'object') { 242 | var keys = Object.keys(obj); 243 | keys.sort(); 244 | var text = '{ '; 245 | for(var i = 0; i < keys.length; i++) { 246 | if (i !== 0) { 247 | text += ', '; 248 | } 249 | text += keys[i] + ': ' + dumpObj(obj[keys[i]]); 250 | } 251 | text += ' }'; 252 | return text; 253 | } else { 254 | return JSON.stringify(obj); 255 | } 256 | } 257 | }); 258 | -------------------------------------------------------------------------------- /lib/parse-html.js: -------------------------------------------------------------------------------- 1 | var firepad = firepad || { }; 2 | 3 | /** 4 | * Helper to parse html into Firepad-compatible lines / text. 5 | * @type {*} 6 | */ 7 | firepad.ParseHtml = (function () { 8 | var LIST_TYPE = firepad.LineFormatting.LIST_TYPE; 9 | 10 | /** 11 | * Represents the current parse state as an immutable structure. To create a new ParseState, use 12 | * the withXXX methods. 13 | * 14 | * @param opt_listType 15 | * @param opt_lineFormatting 16 | * @param opt_textFormatting 17 | * @constructor 18 | */ 19 | function ParseState(opt_listType, opt_lineFormatting, opt_textFormatting) { 20 | this.listType = opt_listType || LIST_TYPE.UNORDERED; 21 | this.lineFormatting = opt_lineFormatting || firepad.LineFormatting(); 22 | this.textFormatting = opt_textFormatting || firepad.Formatting(); 23 | } 24 | 25 | ParseState.prototype.withTextFormatting = function(textFormatting) { 26 | return new ParseState(this.listType, this.lineFormatting, textFormatting); 27 | }; 28 | 29 | ParseState.prototype.withLineFormatting = function(lineFormatting) { 30 | return new ParseState(this.listType, lineFormatting, this.textFormatting); 31 | }; 32 | 33 | ParseState.prototype.withListType = function(listType) { 34 | return new ParseState(listType, this.lineFormatting, this.textFormatting); 35 | }; 36 | 37 | ParseState.prototype.withIncreasedIndent = function() { 38 | var lineFormatting = this.lineFormatting.indent(this.lineFormatting.getIndent() + 1); 39 | return new ParseState(this.listType, lineFormatting, this.textFormatting); 40 | }; 41 | 42 | ParseState.prototype.withAlign = function(align) { 43 | var lineFormatting = this.lineFormatting.align(align); 44 | return new ParseState(this.listType, lineFormatting, this.textFormatting); 45 | }; 46 | 47 | /** 48 | * Mutable structure representing the current parse output. 49 | * @constructor 50 | */ 51 | function ParseOutput() { 52 | this.lines = [ ]; 53 | this.currentLine = []; 54 | this.currentLineListItemType = null; 55 | } 56 | 57 | ParseOutput.prototype.newlineIfNonEmpty = function(state) { 58 | this.cleanLine_(); 59 | if (this.currentLine.length > 0) { 60 | this.newline(state); 61 | } 62 | }; 63 | 64 | ParseOutput.prototype.newlineIfNonEmptyOrListItem = function(state) { 65 | this.cleanLine_(); 66 | if (this.currentLine.length > 0 || this.currentLineListItemType !== null) { 67 | this.newline(state); 68 | } 69 | }; 70 | 71 | ParseOutput.prototype.newline = function(state) { 72 | this.cleanLine_(); 73 | var lineFormatting = state.lineFormatting; 74 | if (this.currentLineListItemType !== null) { 75 | lineFormatting = lineFormatting.listItem(this.currentLineListItemType); 76 | this.currentLineListItemType = null; 77 | } 78 | 79 | this.lines.push(firepad.Line(this.currentLine, lineFormatting)); 80 | this.currentLine = []; 81 | }; 82 | 83 | ParseOutput.prototype.makeListItem = function(type) { 84 | this.currentLineListItemType = type; 85 | }; 86 | 87 | ParseOutput.prototype.cleanLine_ = function() { 88 | // Kinda' a hack, but we remove leading and trailing spaces (since these aren't significant in html) and 89 | // replaces nbsp's with normal spaces. 90 | if (this.currentLine.length > 0) { 91 | var last = this.currentLine.length - 1; 92 | this.currentLine[0].text = this.currentLine[0].text.replace(/^ +/, ''); 93 | this.currentLine[last].text = this.currentLine[last].text.replace(/ +$/g, ''); 94 | for(var i = 0; i < this.currentLine.length; i++) { 95 | this.currentLine[i].text = this.currentLine[i].text.replace(/\u00a0/g, ' '); 96 | } 97 | } 98 | // If after stripping trailing whitespace, there's nothing left, clear currentLine out. 99 | if (this.currentLine.length === 1 && this.currentLine[0].text === '') { 100 | this.currentLine = []; 101 | } 102 | }; 103 | 104 | var entityManager_; 105 | function parseHtml(html, entityManager) { 106 | // Create DIV with HTML (as a convenient way to parse it). 107 | var div = (firepad.document || document).createElement('div'); 108 | div.innerHTML = html; 109 | 110 | // HACK until I refactor this. 111 | entityManager_ = entityManager; 112 | 113 | var output = new ParseOutput(); 114 | var state = new ParseState(); 115 | parseNode(div, state, output); 116 | 117 | return output.lines; 118 | } 119 | 120 | // Fix IE8. 121 | var Node = Node || { 122 | ELEMENT_NODE: 1, 123 | TEXT_NODE: 3 124 | }; 125 | 126 | function parseNode(node, state, output) { 127 | // Give entity manager first crack at it. 128 | if (node.nodeType === Node.ELEMENT_NODE) { 129 | var entity = entityManager_.fromElement(node); 130 | if (entity) { 131 | output.currentLine.push(new firepad.Text( 132 | firepad.sentinelConstants.ENTITY_SENTINEL_CHARACTER, 133 | new firepad.Formatting(entity.toAttributes()) 134 | )); 135 | return; 136 | } 137 | } 138 | 139 | switch (node.nodeType) { 140 | case Node.TEXT_NODE: 141 | // This probably isn't exactly right, but mostly works... 142 | var text = node.nodeValue.replace(/[ \n\t]+/g, ' '); 143 | output.currentLine.push(firepad.Text(text, state.textFormatting)); 144 | break; 145 | case Node.ELEMENT_NODE: 146 | var style = node.getAttribute('style') || ''; 147 | state = parseStyle(state, style); 148 | switch (node.nodeName.toLowerCase()) { 149 | case 'div': 150 | case 'h1': 151 | case 'h2': 152 | case 'h3': 153 | case 'p': 154 | output.newlineIfNonEmpty(state); 155 | parseChildren(node, state, output); 156 | output.newlineIfNonEmpty(state); 157 | break; 158 | case 'center': 159 | state = state.withAlign('center'); 160 | output.newlineIfNonEmpty(state); 161 | parseChildren(node, state.withAlign('center'), output); 162 | output.newlineIfNonEmpty(state); 163 | break; 164 | case 'b': 165 | case 'strong': 166 | parseChildren(node, state.withTextFormatting(state.textFormatting.bold(true)), output); 167 | break; 168 | case 'u': 169 | parseChildren(node, state.withTextFormatting(state.textFormatting.underline(true)), output); 170 | break; 171 | case 'i': 172 | case 'em': 173 | parseChildren(node, state.withTextFormatting(state.textFormatting.italic(true)), output); 174 | break; 175 | case 's': 176 | parseChildren(node, state.withTextFormatting(state.textFormatting.strike(true)), output); 177 | break; 178 | case 'font': 179 | var face = node.getAttribute('face'); 180 | var color = node.getAttribute('color'); 181 | var size = parseInt(node.getAttribute('size')); 182 | if (face) { state = state.withTextFormatting(state.textFormatting.font(face)); } 183 | if (color) { state = state.withTextFormatting(state.textFormatting.color(color)); } 184 | if (size) { state = state.withTextFormatting(state.textFormatting.fontSize(size)); } 185 | parseChildren(node, state, output); 186 | break; 187 | case 'br': 188 | output.newline(state); 189 | break; 190 | case 'ul': 191 | output.newlineIfNonEmptyOrListItem(state); 192 | var listType = node.getAttribute('class') === 'firepad-todo' ? LIST_TYPE.TODO : LIST_TYPE.UNORDERED; 193 | parseChildren(node, state.withListType(listType).withIncreasedIndent(), output); 194 | output.newlineIfNonEmpty(state); 195 | break; 196 | case 'ol': 197 | output.newlineIfNonEmptyOrListItem(state); 198 | parseChildren(node, state.withListType(LIST_TYPE.ORDERED).withIncreasedIndent(), output); 199 | output.newlineIfNonEmpty(state); 200 | break; 201 | case 'li': 202 | parseListItem(node, state, output); 203 | break; 204 | case 'style': // ignore. 205 | break; 206 | default: 207 | parseChildren(node, state, output); 208 | break; 209 | } 210 | break; 211 | default: 212 | // Ignore other nodes (comments, etc.) 213 | break; 214 | } 215 | } 216 | 217 | function parseChildren(node, state, output) { 218 | if (node.hasChildNodes()) { 219 | for(var i = 0; i < node.childNodes.length; i++) { 220 | parseNode(node.childNodes[i], state, output); 221 | } 222 | } 223 | } 224 | 225 | function parseListItem(node, state, output) { 226 | // Note:
    4. is weird: 227 | // * Only the first line in the
    5. tag should be a list item (i.e. with a bullet or number next to it). 228 | // *
    6. should create an empty list item line;
    7. should create two. 229 | 230 | output.newlineIfNonEmptyOrListItem(state); 231 | 232 | var listType = (node.getAttribute('class') === 'firepad-checked') ? LIST_TYPE.TODOCHECKED : state.listType; 233 | output.makeListItem(listType); 234 | var oldLine = output.currentLine; 235 | 236 | parseChildren(node, state, output); 237 | 238 | if (oldLine === output.currentLine || output.currentLine.length > 0) { 239 | output.newline(state); 240 | } 241 | } 242 | 243 | function parseStyle(state, styleString) { 244 | var textFormatting = state.textFormatting; 245 | var lineFormatting = state.lineFormatting; 246 | var styles = styleString.split(';'); 247 | for(var i = 0; i < styles.length; i++) { 248 | var stylePieces = styles[i].split(':'); 249 | if (stylePieces.length !== 2) 250 | continue; 251 | var prop = firepad.utils.trim(stylePieces[0]).toLowerCase(); 252 | var val = firepad.utils.trim(stylePieces[1]).toLowerCase(); 253 | switch (prop) { 254 | case 'text-decoration': 255 | var underline = val.indexOf('underline') >= 0; 256 | var strike = val.indexOf('line-through') >= 0; 257 | textFormatting = textFormatting.underline(underline).strike(strike); 258 | break; 259 | case 'font-weight': 260 | var bold = (val === 'bold') || parseInt(val) >= 600; 261 | textFormatting = textFormatting.bold(bold); 262 | break; 263 | case 'font-style': 264 | var italic = (val === 'italic' || val === 'oblique'); 265 | textFormatting = textFormatting.italic(italic); 266 | break; 267 | case 'color': 268 | textFormatting = textFormatting.color(val); 269 | break; 270 | case 'background-color': 271 | textFormatting = textFormatting.backgroundColor(val); 272 | break; 273 | case 'text-align': 274 | lineFormatting = lineFormatting.align(val); 275 | break; 276 | case 'font-size': 277 | var size = null; 278 | var allowedValues = ['px','pt','%','em','xx-small','x-small','small','medium','large','x-large','xx-large','smaller','larger']; 279 | if (firepad.utils.stringEndsWith(val, allowedValues)) { 280 | size = val; 281 | } 282 | else if (parseInt(val)) { 283 | size = parseInt(val)+'px'; 284 | } 285 | if (size) { 286 | textFormatting = textFormatting.fontSize(size); 287 | } 288 | break; 289 | case 'font-family': 290 | var font = firepad.utils.trim(val.split(',')[0]); // get first font. 291 | font = font.replace(/['"]/g, ''); // remove quotes. 292 | font = font.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() }); 293 | textFormatting = textFormatting.font(font); 294 | break; 295 | } 296 | } 297 | return state.withLineFormatting(lineFormatting).withTextFormatting(textFormatting); 298 | } 299 | 300 | return parseHtml; 301 | })(); 302 | -------------------------------------------------------------------------------- /test/specs/integration.spec.js: -------------------------------------------------------------------------------- 1 | describe('Integration tests', function() { 2 | var h = helpers; 3 | var Firepad = firepad.Firepad; 4 | var Headless = Firepad.Headless; 5 | var extendedTimeoutLength = 300000; 6 | 7 | var _hiddenDiv; 8 | function hiddenDiv() { 9 | if (!_hiddenDiv) { 10 | _hiddenDiv = document.createElement('div'); 11 | _hiddenDiv.style.display = 'none'; 12 | document.body.appendChild(_hiddenDiv); 13 | } 14 | return _hiddenDiv; 15 | } 16 | 17 | function waitFor(check, callback) { 18 | var iid = setInterval(function() { 19 | if(check()){ 20 | clearInterval(iid); 21 | callback(); 22 | } 23 | }, 15); 24 | } 25 | 26 | function randomEdit (cm) { 27 | var length = cm.getValue().length; 28 | var start = h.randomInt(length); 29 | var startPos = cm.posFromIndex(start); 30 | var end = start + h.randomInt(Math.min(10, length - start)); 31 | var endPos = cm.posFromIndex(end); 32 | var newContent = Math.random() > 0.5 ? '' : h.randomString(h.randomInt(12)); 33 | cm.replaceRange(newContent, startPos, endPos); 34 | } 35 | 36 | function randomChange (cm) { 37 | var n = 1 + h.randomInt(4); 38 | while (n--) { 39 | randomEdit(cm); 40 | } 41 | } 42 | 43 | function randomOperation (cm) { 44 | cm.operation(function() { 45 | randomChange(cm); 46 | }); 47 | } 48 | 49 | var rootRef; 50 | 51 | beforeEach(function(done) { 52 | // Make sure we're connected to Firebase. This can take a while on slow 53 | // connections. 54 | rootRef = firebase.database().ref(); 55 | var connectedRef = rootRef.child('.info/connected'); 56 | var connected = false; 57 | var listener = connectedRef.on('value', function(s) { 58 | if (s.val() == true) { 59 | done(); 60 | connectedRef.off('value', listener); 61 | } 62 | }); 63 | 64 | firebase.database().ref('1').remove(); 65 | firebase.database().ref('2').remove(); 66 | }, extendedTimeoutLength); 67 | 68 | // Passes locally, but times out of Travis regardless of timeout interval 69 | it('Out-of-order edit', function (done) { 70 | var ref = rootRef.push(); 71 | var cm1 = CodeMirror(hiddenDiv()); 72 | var cm2 = CodeMirror(hiddenDiv()); 73 | var firepad1 = new Firepad(ref, cm1); 74 | var firepad2 = new Firepad(ref, cm2); 75 | 76 | firepad1.on('ready', function() { 77 | firepad1.setText('XXX3456789XXX'); 78 | cm1.operation(function() { 79 | cm1.replaceRange('', {line: 0, ch: 10}, {line: 0, ch: 13}); 80 | cm1.replaceRange('', {line: 0, ch: 0}, {line: 0, ch: 3}); 81 | }); 82 | cm2.on('change', function() { 83 | if (cm2.getValue() === '3456789') { 84 | expect(cm2.getValue()).toEqual('3456789'); 85 | done(); 86 | } 87 | }); 88 | }); 89 | }, extendedTimeoutLength); 90 | 91 | // Passes locally, but times out of Travis regardless of timeout interval 92 | it('Random text changes', function(done) { 93 | var ref = rootRef.push(); 94 | var cm1 = CodeMirror(hiddenDiv()); 95 | var cm2 = CodeMirror(hiddenDiv()); 96 | var firepad1 = new Firepad(ref, cm1); 97 | var firepad2 = new Firepad(ref, cm2); 98 | 99 | function step(times) { 100 | if (times == 0) { 101 | expect(cm1.getValue()).toEqual(cm2.getValue()); 102 | done(); 103 | } else { 104 | randomOperation(cm1); 105 | waitFor(function() { 106 | return cm1.getValue() === cm2.getValue(); 107 | }, function() { 108 | step(times - 1); 109 | }); 110 | } 111 | } 112 | 113 | firepad1.on('ready', function() { 114 | firepad1.setText('lorem ipsum'); 115 | step(25); 116 | }); 117 | }, extendedTimeoutLength); 118 | 119 | it('Performs getHtml responsively', function(done) { 120 | var ref = rootRef.push(); 121 | var cm = CodeMirror(hiddenDiv()); 122 | var firepad = new Firepad(ref, cm); 123 | 124 | firepad.on('ready', function() { 125 | var html = 'bold'; 126 | firepad.setHtml(html); 127 | expect(firepad.getHtml()).toContain(html); 128 | done(); 129 | }); 130 | }, extendedTimeoutLength); 131 | 132 | it('Uses defaultText to initialize the pad properly', function(done) { 133 | var ref = rootRef.push(); 134 | var cm = CodeMirror(hiddenDiv()); 135 | var cm2 = CodeMirror(hiddenDiv()); 136 | var text = 'This should be the starting text'; 137 | var text2 = 'this is a new, different text'; 138 | var firepad = new Firepad(ref, cm, { defaultText: text}); 139 | 140 | firepad.on('ready', function() { 141 | expect(firepad.getText()).toEqual(text); 142 | firepad.setText(text2); 143 | var waitForSync = new Promise(function(resolve) { 144 | firepad.on('synced', function(isSync) { if (isSync) resolve(); }); 145 | }); 146 | waitForSync.then(function() { 147 | var firepad2 = new Firepad(ref, cm2, { defaultText: text}); 148 | firepad2.on('ready', function() { 149 | if (firepad2.getText() == text2) { 150 | done(); 151 | } else if (firepad2.getText() == text) { 152 | done(new Error('Default text won over edited text')); 153 | } else { 154 | done(new Error('Second Firepad got neither default nor edited text: ' + JSON.stringify(firepad2.getText()))); 155 | } 156 | }); 157 | }); 158 | }); 159 | }); 160 | 161 | it('Emits sync events as users edit the pad', function(done) { 162 | var ref = rootRef.push(); 163 | var cm = CodeMirror(hiddenDiv()); 164 | var firepad = new Firepad(ref, cm, { defaultText: 'XXXXXXXX' }); 165 | var startedSyncing = false; 166 | 167 | firepad.on('ready', function() { 168 | randomOperation(cm); 169 | firepad.on('synced', function(synced) { 170 | if (startedSyncing) { 171 | if (synced == true) { 172 | done(); 173 | } 174 | } else { 175 | expect(synced).toBe(false); 176 | startedSyncing = true; 177 | } 178 | }); 179 | }); 180 | }); 181 | 182 | it('Performs Firepad.dispose', function(done){ 183 | var ref = rootRef.push(); 184 | var cm = CodeMirror(hiddenDiv()); 185 | var firepad = new Firepad(ref, cm, { defaultText: "It\'s alive." }); 186 | 187 | firepad.on('ready', function() { 188 | firepad.dispose(); 189 | // We'd like to know all firebase callbacks were removed. 190 | // This does not prove there was no leak but it shows we tried. 191 | expect(firepad.firebaseAdapter_.firebaseCallbacks_).toEqual([]); 192 | expect(function() { firepad.isHistoryEmpty(); }).toThrow(); 193 | expect(function() { firepad.getText(); }).toThrow(); 194 | expect(function() { firepad.setText("I'm a zombie. Braaaains..."); }).toThrow(); 195 | expect(function() { firepad.getHtml(); }).toThrow(); 196 | expect(function() { firepad.setHtml("

      I'm a zombie. Braaaains...

      "); }).toThrow(); 197 | done(); 198 | }); 199 | }); 200 | 201 | it('Safely performs Firepad.dispose immediately after construction', function(){ 202 | var ref =rootRef.push(); 203 | var cm = CodeMirror(hiddenDiv()); 204 | var firepad = new Firepad(ref, cm); 205 | 206 | expect(function() { 207 | firepad.dispose(); 208 | }).not.toThrow(); 209 | }); 210 | 211 | it('Performs headless get/set plaintext & dispose', function(done){ 212 | var ref = rootRef.push(); 213 | var cm = CodeMirror(hiddenDiv()); 214 | var firepadCm = new Firepad(ref, cm); 215 | var firepadHeadless = new Headless(ref); 216 | 217 | var text = 'Hello from headless firepad!'; 218 | 219 | firepadHeadless.setText(text, function() { 220 | firepadHeadless.getText(function(headlessText) { 221 | expect(headlessText).toEqual(firepadCm.getText()); 222 | expect(headlessText).toEqual(text); 223 | 224 | firepadHeadless.dispose(); 225 | // We'd like to know all firebase callbacks were removed. 226 | // This does not prove there was no leak but it shows we tried. 227 | expect(firepadHeadless.firebaseAdapter_.firebaseCallbacks_).toEqual([]); 228 | expect(function() { firepadHeadless.getText(function() {}); }).toThrow(); 229 | expect(function() { firepadHeadless.setText("I'm a zombie. Braaaains..."); }).toThrow(); 230 | done(); 231 | }); 232 | }); 233 | }); 234 | 235 | it('Performs headless get/set html & dispose', function(done) { 236 | var ref = rootRef.push(); 237 | var cm = CodeMirror(hiddenDiv()); 238 | var firepadCm = new Firepad(ref, cm); 239 | var firepadHeadless = new Headless(ref); 240 | 241 | var html = 242 | 'Rich-text editing with Firepad!
      \n' + 243 | '
      ' + 244 | '
      ' + 245 | 'Supports:
      ' + 246 | '
        ' + 247 | '
      • Different ' + 248 | 'fonts,' + 249 | ' sizes, ' + 250 | 'and colors.' + 251 | '
      • ' + 252 | '
      • ' + 253 | 'Bold, ' + 254 | 'italic, ' + 255 | 'and underline.' + 256 | '
      • ' + 257 | '
      • Lists' + 258 | '
          ' + 259 | '
        1. One
        2. ' + 260 | '
        3. Two
        4. ' + 261 | '
        ' + 262 | '
      • ' + 263 | '
      • Undo / redo
      • ' + 264 | '
      • Cursor / selection synchronization.
      • ' + 265 | '
      • And it\'s all fully collaborative!
      • ' + 266 | '
      ' + 267 | '
      '; 268 | 269 | firepadHeadless.setHtml(html, function() { 270 | firepadHeadless.getHtml(function(headlessHtml) { 271 | expect(headlessHtml).toEqual(firepadCm.getHtml()); 272 | 273 | firepadHeadless.dispose(); 274 | // We'd like to know all firebase callbacks were removed. 275 | // This does not prove there was no leak but it shows we tried. 276 | expect(firepadHeadless.firebaseAdapter_.firebaseCallbacks_).toEqual([]); 277 | expect(function() { firepadHeadless.getHtml(function() {}); }).toThrow(); 278 | expect(function() { firepadHeadless.setHtml("

      I'm a zombie. Braaaains...

      "); }).toThrow(); 279 | done(); 280 | }); 281 | }); 282 | }); 283 | 284 | it('Headless firepad takes a string path as well', function(done) { 285 | var ref = rootRef.push(); 286 | var text = 'Hello from headless firepad!'; 287 | var firepadHeadless = new Headless(ref.toString()); 288 | 289 | firepadHeadless.setText(text, function() { 290 | firepadHeadless.getText(function(headlessText) { 291 | expect(headlessText).toEqual(text); 292 | done(); 293 | }); 294 | }); 295 | }); 296 | 297 | it('Ace editor', function (done) { 298 | var ref = rootRef.push(); 299 | 300 | var editor = ace.edit(hiddenDiv().appendChild(document.createElement('div'))); 301 | 302 | var text = '// JavaScript in Firepad!\nfunction log(message) {\n console.log(message);\n}'; 303 | var firepad = Firepad.fromACE(ref, editor); 304 | 305 | firepad.on('ready', function() { 306 | firepad.setText(text); 307 | expect(firepad.getText()).toEqual(text); 308 | done(); 309 | }); 310 | }); 311 | 312 | it('Safely performs Headless.dispose immediately after construction', function(){ 313 | var ref = rootRef.push(); 314 | var firepadHeadless = new Headless(ref); 315 | 316 | expect(function() { 317 | firepadHeadless.dispose(); 318 | }).not.toThrow(); 319 | }); 320 | 321 | it('Perform dispose - immediatly removes callbacks', function(done){ 322 | var ref1 = rootRef.push(); 323 | var cm = CodeMirror(hiddenDiv()); 324 | 325 | expect(function() { 326 | var firepad = new Firepad(ref1, cm, { defaultText: 'Default Content'}); 327 | firepad.dispose() 328 | // Wait some time for the callbacks to get called 329 | setTimeout(done, 1) 330 | }).not.toThrow(); 331 | }) 332 | 333 | it('Perform dispose - immediatly noop updates to text editor', function(done){ 334 | var ref1 = firebase.database().ref('1').push(); 335 | var ref2 = firebase.database().ref('2').push(); 336 | 337 | var cm = CodeMirror(hiddenDiv()); 338 | var firepad1 = new Firepad(ref1, cm); 339 | 340 | firepad1.on('ready', function() { 341 | // Add some text to Firepad 342 | expect(cm.getValue()).toEqual(''); 343 | firepad1.setText('Test Content'); 344 | 345 | firepad1.on('synced', function(isSynced){ 346 | if(isSynced) { 347 | firepad1.dispose(); 348 | cm.setValue(''); 349 | 350 | // Create a new Firepad, using the same ref which we added text to, then dispose it 351 | var firepad2 = new Firepad(ref1, cm); 352 | firepad2.dispose() 353 | firepad2.on('ready', () => { 354 | expect(cm.getValue()).toEqual('Test Content'); 355 | }) 356 | cm.setValue(''); 357 | 358 | // Create a new Firepad instance with a different ref 359 | // Should not contain text from previously disposed firepad 360 | var firepad3 = new Firepad(ref2, cm); 361 | firepad3.on('ready', function(synced) { 362 | expect(cm.getValue()).toEqual(''); 363 | done(); 364 | }) 365 | } 366 | }) 367 | }) 368 | }) 369 | }); 370 | --------------------------------------------------------------------------------