34 |
Hammer Time!
35 |
Hammering!
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] [](https://coveralls.io/r/FirebaseExtended/firepad) [](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 | 
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 | [](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 += "