Open this in another tab aswell to see it in action
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
137 |
138 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # realtime-editor
2 | Ever had the same feeling as us about how complicated and soul-crushing it can be to implement some sort of a collaborative editor? ..Even a simple one?
3 |
4 | If Etherpad are either too big or too much of what you need and shareJS doesnt fit your application (as ours don't since we build upon [socket.io](https://www.npmjs.com/package/socket.io) this plugin might be what you're looking for.
5 |
6 | Here at [T.A.K.E.](http://takedesign.dk/) we have made a very simple "textarea" where the only needs required were to have it be on some sort of collaborative level while not requiring insane amount of server configuration nor external/extra db logic.
7 |
8 |
9 | This realtime-editor is a lightweight node module with a server and client side script. It uses [socket.io](https://www.npmjs.com/package/socket.io) and [diff-match-patch](https://code.google.com/p/google-diff-match-patch/). It doesnt solve all the collaborative problems or needs but if it fits your needs go ahead and give it a try.
10 |
11 |
12 | NOTE: Before we begin, this is a really early beta version and is quite unstable.. more updates and documentations is on its way..
13 |
14 |
15 | Setup
16 | --------
17 | Npm install the sucker and include it to your server index.js
18 |
19 | ```js
20 | npm install realtime-editor
21 | ```
22 | ```js
23 | var realtimeEditor = require('realtime-editor');
24 | ```
25 |
26 | Add the client part aswell to your application's index.html
27 |
28 | ```html
29 |
30 | ```
31 |
32 | And dont forget the 2 dependencies socket.io and diff-match-patch client parts aswell if you dont have them included allready
33 |
34 | ```html
35 |
36 |
37 | ```
38 |
39 |
40 | Usage
41 | --------
42 |
43 | It's currently build around MDL-Lite's material design framework but it should work without it (Dont blame us if doesnt!).
44 |
45 | For the MDL styles check the example in the demo folder. For now, here is the bare one. Feel free to include your own styles and a label tag inside the div wrapper at the bottom
46 |
47 | ```html
48 |
49 |
50 |
51 |
52 |
53 | ```
54 |
55 | Now init socket.io client part and the the textarea through javascript
56 |
57 | ```js
58 | var socket = io.connect();
59 |
60 | var editor = new realtimeEditor(options);
61 | ```
62 |
63 | The options argument needs atleast the id of the text field aswell as an unique identifier fx a project id.
64 | It takes several others optional parameters such as an user color.
65 |
66 | The text property consist of an array with an object for each line created in it. The array can either start empty or with some data (fx. stored from your database).
67 | The format of the objects inside the text array needs to have the properties as shown below, alltho they are auto generated when new lines are created, but make sure you save the whole text array when storing it to your database.
68 |
69 | ```js
70 | var options = {
71 | id: 'textarea1', // unique to the textfield
72 | projectId: 'someUniqueIdentifier', // required in order to have several active editors on the same page
73 | room: 'uniqueTextRoom', // unique room id, default is projectId combined with the element id
74 | text: [ // init the textarea with the newest text
75 | {
76 | author: '',
77 | text: 'line_1',
78 | id: '1459856606818_16407750' // id of the line auto generated.
79 | },
80 | {
81 | author: '',
82 | text: 'line_2',
83 | id: '1459865117436_19682870'
84 | },
85 | {
86 | author: '',
87 | text: 'line_3',
88 | id: '1459865208855_19888940'
89 | }
90 | ],
91 | custom: { // custom object such as specific appication IDs. Fx in order to save it on the server side
92 | appId: 1,
93 | customProperty: 'some_application_specific_here'
94 | }
95 | };
96 |
97 | new realtimeEditor(options);
98 | ```
99 |
100 |
101 | Options
102 | --------
103 |
104 | | Parameter | Type | Default | Description |
105 | | ------------- | --------- | ------------- | --------------------------------------------------------------------- |
106 | | id | string | undefined | The id of the textarea. Is requried |
107 | | projectId | integer | 1 | Will be renamed at some point. is required in order to have multiple editors on same page |
108 | | room | string | projectId + id| Room name for socket.io. Make sure its unqiue in order to avoid conflicts. Default is the id of the textarea |
109 | | color | string | random | Set a user color as such #1d1d1d |
110 | | author | string | random | Set an id of the user. make sure its unique and no spaces |
111 | | authorName | string | random | Set name of author. random name is generated if none applied. Not complete |
112 | | message | string | Connection lost. please wait.. | Message will be desplayed below when socket connection is lost. Change it here to fit your language |
113 | | custom | object | {} | This is where you add your applications specific properties incase you want to do something with the data like save it to your own db in a hook |
114 |
115 |
116 | Hooks
117 | --------
118 |
119 | On your server side you can add a hook which will fire when something changes
120 |
121 |
122 | ```js
123 | var editor = new realtimeEditor(options);
124 |
125 | realtimeEditor.onSave(function (data) {
126 | // do something with the data object here like stringify it and save it to your fauvorite db
127 | });
128 | ```
129 |
130 |
131 | Demo
132 | --------
133 |
134 | A demo is included. Check it out by cloning the demo folder, go into it and run ```npm install``` followed by a ```node demo.js```
135 | Open your browser and go to http://localhost:2000 to see the example
136 |
137 |
138 | Todo
139 | --------
140 | * Atm you cant write on same line as it updates the text per line
141 | * More stable version aka. better server testing / fallback
142 | * Undo/redo availability (keyboard shortcuts)
143 | * More test!
144 | * Author text string on an individual line is not getting set correctly atm
145 | * Gif demo example.. gotta have those animated gifs!
146 | * maybe include text styling in the long run like a WYSIWYG editor
147 | * did I mention test?
148 |
149 |
150 | Keep making it better
151 | --------
152 | Feel free to donate in order to help us out.
153 | Any amount will be greatly appreciated, for the many hours invested into this, aswell as in future developement.
154 |
155 | [](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=WBXRF3VJD2MJY)
156 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | // realtime-editor
2 | //
3 | // server side part
4 | //
5 |
6 | var emitter = require('events'),
7 | //io = require('socket.io')(http),
8 | diffMatchPatch = require('diff-match-patch'),
9 | dmp = new diffMatchPatch();
10 |
11 |
12 | function realtimeEditor (parameters, custom) {
13 | this.emitter = new emitter();
14 |
15 | this.options = parameters || {};
16 | this.textarea = {};
17 |
18 | this.init();
19 | }
20 |
21 |
22 | // init the socket.io connection to coEditor
23 | realtimeEditor.prototype.init = function () {
24 | var that = this;
25 |
26 | io.sockets.on('connection', function (socket) {
27 |
28 | socket.on('rtEditorSync', function (data, callback) {
29 | that.syncText(data, function (res) {
30 | socket.broadcast.to(data.room).emit('rtEditorBroadcast', res);
31 | });
32 | });
33 |
34 | socket.on('rtEditorJoin', function (data, callback) {
35 | socket.rtEditor = {
36 | room: data.room
37 | };
38 |
39 | socket.join(data.room, function () {
40 | // check if there is a data object allready
41 |
42 | /*if (that.textarea[data.projectId] !== undefined) {
43 | if (that.textarea[data.projectId][data.targetId] !== undefined) {
44 |
45 | data.text = that.textarea[data.projectId][data.targetId].data;
46 | }
47 | }*/
48 |
49 | //console.log('join room:', data.room);
50 |
51 |
52 | if (that.textarea[data.projectId] === undefined) {
53 | that.textarea[data.projectId] = {
54 | projectId: data.projectId
55 | };
56 | }
57 |
58 | if (that.textarea[data.projectId][data.targetId] === undefined) {
59 | that.textarea[data.projectId][data.targetId] = {
60 | targetId: data.targetId,
61 | data: data.text,
62 | timeout: 0
63 | };
64 | }
65 |
66 | if (callback !== undefined) {
67 | callback({mesage: 'done joining the room: ' + data.room, data: that.textarea[data.projectId][data.targetId].data});
68 | }
69 | });
70 | });
71 |
72 | socket.on('rtEditorRejoin', function (data, callback) {
73 | socket.join(data.room, function () {
74 | if (callback !== undefined) {
75 | callback({mesage: 'done rejoining the room: ' + data.room, data: data});
76 | }
77 | });
78 | });
79 |
80 | socket.on('rtEditorExit', function (data, callback) {
81 | socket.leave(data.room, function () {
82 | if (callback !== undefined) {
83 | callback({mesage: 'done leaving room: ' + data.room, data: data});
84 | }
85 | });
86 | });
87 |
88 | socket.on('disconnect', function () {
89 | /*console.log('on plugin disconnect', socket.rtEditor);
90 |
91 | for (var editor in socket.rtEditor) {
92 | console.log('clear cursor from this:', socket.rtEditor[editor]);
93 |
94 | socket.broadcast.to(data.room).emit('rtEditorBroadcast', {});
95 | }*/
96 | });
97 |
98 | });
99 | };
100 |
101 |
102 | // emit back to parameter callback
103 | realtimeEditor.prototype.emit = function (name, data) {
104 | if (this.options[name] !== undefined) {
105 | this.options[name](data);
106 | }
107 | };
108 |
109 | // sync the text to the server object
110 | realtimeEditor.prototype.syncText = function (data, callback) {
111 | //console.log('syncText', data);
112 |
113 | var line, previousLine, currentText, currentTextIndex, previousLineIndex, loopedLine,
114 | diff, patchText, resultText,
115 | that = this;
116 |
117 | //console.log('data', data);
118 |
119 | if (this.textarea[data.projectId] === undefined) {
120 | this.textarea[data.projectId] = {
121 | projectId: data.projectId
122 | };
123 | }
124 |
125 | if (this.textarea[data.projectId][data.targetId] === undefined) {
126 | // create object if first time and save all current lines
127 | this.textarea[data.projectId][data.targetId] = {
128 | targetId: data.targetId,
129 | data: data.savedLines
130 | };
131 | }
132 |
133 | // patch changes only and save all current lines after patch
134 | //console.log('patch');
135 |
136 | for (var l = 0; l < this.textarea[data.projectId][data.targetId].data.length; l++) {
137 | line = this.textarea[data.projectId][data.targetId].data[l];
138 |
139 | // find line to patch
140 | if (line.id === data.activeLineId) {
141 | currentText = line.text;
142 | currentTextIndex = l;
143 | //console.log('found currentText to patch', data.activeLineText);
144 | }
145 |
146 | // find previous line if any
147 | if (line.id === data.previousLineId) {
148 | previousLine = line.text;
149 | previousLineIndex = l;
150 |
151 | //console.log('found previousLine to fix', data.previousLineText);
152 | }
153 | }
154 |
155 | if (data.type === 'modifyLine') { // patch existing line
156 | diff = dmp.diff_main(currentText, data.activeLineText);
157 | patchText = dmp.patch_make(currentText, data.activeLineText, diff);
158 | resultText = dmp.patch_apply(patchText, currentText);
159 |
160 | this.textarea[data.projectId][data.targetId].data[currentTextIndex].text = resultText[0];
161 | this.textarea[data.projectId][data.targetId].data[currentTextIndex].author = data.author;
162 | } else if (data.type === 'newLine') { // add new line
163 | if (previousLineIndex === (this.textarea[data.projectId][data.targetId].data.length - 1)) {
164 | // if last line append
165 | this.textarea[data.projectId][data.targetId].data.push({
166 | id: data.activeLineId,
167 | text: data.activeLineText,
168 | author: data.author
169 | });
170 | } else {
171 | // if not last line insertBefore
172 | this.textarea[data.projectId][data.targetId].data.splice(previousLineIndex + 1, 0, {
173 | id: data.activeLineId,
174 | text: data.activeLineText,
175 | author: data.author
176 | });
177 | }
178 | } else if (data.type === 'breakLine') {
179 | if (previousLineIndex === (this.textarea[data.projectId][data.targetId].data.length - 1)) {
180 | // if last line append
181 | this.textarea[data.projectId][data.targetId].data.push({
182 | id: data.activeLineId,
183 | text: data.activeLineText,
184 | author: data.author
185 | });
186 | } else {
187 | // if not last line insertBefore
188 | this.textarea[data.projectId][data.targetId].data.splice(previousLineIndex + 1, 0, {
189 | id: data.activeLineId,
190 | text: data.activeLineText,
191 | author: data.author
192 | });
193 | }
194 |
195 |
196 | diff = dmp.diff_main(previousLine, data.previousLineText);
197 | patchText = dmp.patch_make(previousLine, data.previousLineText, diff);
198 | resultText = dmp.patch_apply(patchText, previousLine);
199 |
200 | this.textarea[data.projectId][data.targetId].data[previousLineIndex].text = (resultText[0] === '' ? ' ' : resultText[0]);
201 | } else if (data.type === 'pastedContent') {
202 | for (var n = 0; n < data.newLines.length; n++) {
203 | if (n === 0) {
204 | // if first line
205 |
206 | for (var d = 0; d < this.textarea[data.projectId][data.targetId].data.length; d++) {
207 | loopedLine = this.textarea[data.projectId][data.targetId].data[d];
208 |
209 | if (loopedLine.id === data.newLines[n].id) {
210 |
211 | diff = dmp.diff_main(loopedLine.text, data.newLines[n].text);
212 | patchText = dmp.patch_make(loopedLine.text, data.newLines[n].text, diff);
213 | resultText = dmp.patch_apply(patchText, loopedLine.text);
214 |
215 | loopedLine.text = resultText[0];
216 |
217 | previousLine = d;
218 | }
219 | }
220 |
221 | // diff & patch first line of content
222 |
223 | } else {
224 | this.textarea[data.projectId][data.targetId].data.splice((previousLine + 1), 0, {
225 | id: data.newLines[n].id,
226 | text: data.newLines[n].text,
227 | author: data.newLines[n].author,
228 | });
229 |
230 | previousLine = n;
231 | }
232 |
233 | }
234 |
235 | }
236 |
237 |
238 | // Handle deleted lines
239 | if (data.deletedLines !== undefined) {
240 | if (data.deletedLines.length > 0) {
241 | for (var l = 0; l < data.deletedLines.length; l++) {
242 | for (var d = this.textarea[data.projectId][data.targetId].data.length - 1; d >= 0; d--) {
243 | if (this.textarea[data.projectId][data.targetId].data[d].id === data.deletedLines[l]) {
244 | this.textarea[data.projectId][data.targetId].data.splice(d, 1);
245 | }
246 | }
247 | }
248 | }
249 | }
250 |
251 |
252 |
253 | //console.log('data', this.textarea[data.projectId][data.targetId]);
254 |
255 | /*if (callback !== undefined) {
256 | callback(this.textarea[data.projectId]);
257 | }*/
258 |
259 |
260 | // server demo test
261 | //var diff = dmp.diff_main(serverText, 'h1 elo');
262 | //var patch_list = dmp.patch_make(serverText, 'h1 elo', diff);
263 | //var result = dmp.patch_apply(patch_list, serverText);
264 |
265 | //console.log('sync text', this.options);
266 |
267 |
268 | // callbacks
269 | callback(data);
270 |
271 | if (data.type !== 'clearCursor' && data.type !== 'moveCursor') {
272 | clearTimeout(this.textarea[data.projectId][data.targetId].timeout);
273 |
274 | // timeout to avoid db spam
275 | this.textarea[data.projectId][data.targetId].timeout = setTimeout(function () {
276 | that.emitter.emit('onSave', data);
277 | }, 700);
278 | }
279 | };
280 |
281 |
282 |
283 | realtimeEditor.prototype.onSave = function (callback) {
284 | this.emitter.on('onSave', function (data) {
285 |
286 | // emit specific information parts
287 | // add more properties from the data object if needed here
288 | var savedData = {
289 | targetId: data.targetId,
290 | author: data.author,
291 | text: data.savedLines,
292 | custom: data.custom
293 | };
294 |
295 | callback(savedData);
296 | });
297 |
298 | };
299 |
300 | module.exports = new realtimeEditor;
--------------------------------------------------------------------------------
/realtime-editor.js:
--------------------------------------------------------------------------------
1 | // realtime-editor
2 | //
3 | // client side part
4 | //
5 |
6 | function realtimeEditor (options) {
7 | var that = this,
8 | random = Math.floor((Math.random() * 100000) + 1),
9 | div;
10 |
11 | // The .bind method from Prototype.js
12 | if (!Function.prototype.bind) { // check if native implementation is not available (it is for ES5+)
13 | Function.prototype.bind = function () {
14 | var fn = this,
15 | args = Array.prototype.slice.call(arguments),
16 | object = args.shift();
17 |
18 | return function () {
19 | return fn.apply(object, args.concat(Array.prototype.slice.call(arguments)));
20 | };
21 | };
22 | }
23 |
24 | // required checks
25 | if (options.id === undefined) {
26 | console.error('realtimeEditor: textarea id is required');
27 |
28 | return;
29 | }
30 |
31 | if (options.text === undefined) {
32 | console.error('realtimeEditor: textarea text array is required');
33 |
34 | return;
35 | }
36 |
37 | // temp values
38 | if (options.author === undefined && sessionStorage.tempRealtimeauthor === undefined) {
39 | sessionStorage.tempRealtimeauthor = 'user' + random;
40 | }
41 |
42 | if (options.authorName === undefined && sessionStorage.tempRealtimeAuthorName === undefined) {
43 | sessionStorage.tempRealtimeAuthorName = 'user' + random;
44 | }
45 |
46 | if (options.color === undefined && sessionStorage.tempRealtimeColor === undefined) {
47 | sessionStorage.tempRealtimeColor = '#' + Math.floor(Math.random() * 16777215).toString(16);
48 | }
49 |
50 | // set standing variables
51 | this.author = options.author || sessionStorage.tempRealtimeauthor;
52 | this.authorName = options.authorName || sessionStorage.tempRealtimeAuthorName;
53 | this.id = options.id;
54 | this.text = (options.text.length > 0 ? options.text : this.emptyLine());
55 | this.color = options.color || sessionStorage.tempRealtimeColor;
56 | this.lineHeight = options.lineHeight || 20;
57 | this.editor = document.getElementById(options.id);
58 | this.projectId = options.projectId || 1;
59 | this.room = (options.room === undefined ? (options.projectId + '' + options.id) : options.room);
60 |
61 | this.message = options.message || 'Connection lost. please wait..';
62 | this.custom = options.custom || {};
63 |
64 | this.update.bind(this);
65 |
66 | if (socket) {
67 | socket.emit('rtEditorJoin', {room: that.room, targetId: that.id, projectId: that.projectId, text: that.text}, function (res) {
68 | that.text = res.data;
69 |
70 | that.loadText();
71 | });
72 |
73 | socket.on('connect', function () {
74 | socket.emit('rtEditorRejoin', {room: that.room}, function (res) {
75 | var data = {
76 | room: that.room,
77 | targetId: that.id,
78 | projectId: that.projectId,
79 | type: 'clearCursor',
80 | savedLines: that.getLines()
81 | };
82 |
83 | that.send(data);
84 |
85 | that.editor.style.opacity = 1;
86 | that.editor.contentEditable = true;
87 |
88 | that.toggleMessage('hide');
89 | });
90 | });
91 |
92 | if (socket.rtEditorBroadcast === undefined) {
93 | socket.rtEditorBroadcast = true;
94 | socket.on('rtEditorBroadcast', function (data) {
95 | that.update(data);
96 | });
97 | }
98 |
99 | if (socket.rtEditorDisconnect === undefined) {
100 | socket.rtEditorDisconnect = true;
101 | socket.on('disconnect', function () {
102 | that.editor.style.opacity = 0.7;
103 | that.editor.contentEditable = false;
104 | that.toggleMessage('show');
105 | });
106 | }
107 |
108 |
109 | } else {
110 | console.error('realtimeEditor: socket.io not detected');
111 | }
112 | }
113 |
114 | // show the text
115 | realtimeEditor.prototype.loadText = function () {
116 | this.editor.addEventListener('focus', this.onFocus.bind(this), false);
117 | this.editor.addEventListener('blur', this.onBlur.bind(this), false);
118 | this.editor.addEventListener('click', this.onClick.bind(this), false);
119 | this.editor.addEventListener('keydown', this.keydown.bind(this), false);
120 | this.editor.addEventListener('keyup', this.keyup.bind(this), false);
121 | this.editor.addEventListener('paste', this.paste.bind(this), false);
122 |
123 | // check if selected field is not active
124 | if (document.activeElement.id !== this.editor.id) {
125 | this.editor.innerHTML = '';
126 |
127 | if (this.text.length > 0) {
128 | for (var t = 0; t < this.text.length; t++) {
129 | div = document.createElement('div');
130 |
131 | div.id = this.text[t].id;
132 | div.innerHTML = this.text[t].text;
133 |
134 | this.editor.appendChild(div);
135 |
136 | this.editor.parentNode.classList.add('is-dirty');
137 | }
138 | } else {
139 | // if existing data array is empty insert default empty lines
140 | this.insertDefault(this.editor);
141 | }
142 | }
143 | };
144 |
145 | // create a new empty line
146 | realtimeEditor.prototype.emptyLine = function () {
147 | return [
148 | {
149 | author: this.author,
150 | text: ' ',
151 | id: new Date().getTime() + '_' + Math.floor(Math.random() * (2000000 - 0)) + 0
152 | }
153 | ];
154 | };
155 |
156 | // on focus add active class
157 | realtimeEditor.prototype.onFocus = function (event) {
158 | event.target.parentNode.classList.add('is-focused');
159 | };
160 |
161 | // on blur remove active class
162 | realtimeEditor.prototype.onBlur = function (event) {
163 | var data = {
164 | room: this.room,
165 | color: this.color,
166 | targetId: this.id,
167 | projectId: this.projectId,
168 | type: 'clearCursor',
169 | author: this.author,
170 | savedLines: this.getLines()
171 | };
172 |
173 | event.target.parentNode.classList.remove('is-focused');
174 |
175 | this.send(data);
176 | };
177 |
178 | // on click
179 | realtimeEditor.prototype.onClick = function (event) {
180 | var data = {
181 | room: this.room,
182 | color: this.color,
183 | targetId: this.id,
184 | projectId: this.projectId,
185 | type: 'moveCursor',
186 | author: this.author,
187 | authorName: this.authorName,
188 | savedLines: this.getLines(),
189 | caretPos: this.getCaret()
190 | };
191 |
192 | if (data.caretPos.activeLine.tagName === 'DIV') {
193 | this.send(data);
194 | }
195 | };
196 |
197 | // get caret line index and offset
198 | realtimeEditor.prototype.getCaret = function (event) {
199 | var selection = window.getSelection(),
200 | caret = {};
201 |
202 | if (selection.anchorNode !== null) {
203 | if (selection.anchorNode.nodeType === 1) {
204 | caret.activeLine = selection.anchorNode;
205 | } else {
206 | caret.activeLine = selection.anchorNode.parentNode;
207 | }
208 |
209 | caret.activeLineId = caret.activeLine.id;
210 | caret.offset = selection.anchorOffset;
211 | caret.lineIndex = [].indexOf.call(caret.activeLine.parentNode.children, caret.activeLine);
212 | }
213 |
214 | return caret;
215 | };
216 |
217 | // Paste
218 | realtimeEditor.prototype.paste = function (event) {
219 | var pasteLine = event.target,
220 | textarea = pasteLine.parentNode,
221 | selection = window.getSelection(),
222 | range = document.createRange(),
223 | pasteLineText = pasteLine.innerHTML,
224 | caretOffset = selection.getRangeAt(0).startOffset,
225 | pastedLines = event.clipboardData.getData('text/plain').split('\n'),
226 | div,
227 | previousLine,
228 | timeId;
229 |
230 | event.preventDefault();
231 |
232 | pasteLineText = pasteLine.innerHTML;
233 |
234 | for (var p = 0; p < pastedLines.length; p++) {
235 | // special stuff for first pasted line
236 | if (p === 0) {
237 |
238 | // add remaining of line one if only one line was pasted in
239 | if (pastedLines.length === 1) {
240 | pasteLine.innerHTML = pasteLineText.substr(0, caretOffset) + pastedLines[p] + pasteLineText.substr(caretOffset);
241 | } else {
242 | pasteLine.innerHTML = pasteLineText.substr(0, caretOffset) + pastedLines[p];
243 | }
244 |
245 | previousLine = pasteLine;
246 | } else {
247 | div = document.createElement('div');
248 | timeId = new Date().getTime() + '_' + Math.floor(Math.random() * (2000000 - 0)) + 0;
249 |
250 | div.id = timeId;
251 | div.innerHTML = (p === (pastedLines.length - 1) ? pastedLines[p] + pasteLineText.substr(caretOffset) : pastedLines[p]);
252 |
253 | textarea.insertBefore(div, previousLine.nextSibling);
254 |
255 | previousLine = div;
256 | }
257 |
258 | this.newLines.push({
259 | id: (p === 0 ? pasteLine.id : timeId),
260 | text: previousLine.innerHTML.replace(/<\/?[^>]+(>|$)/g, ''),
261 | author: sessionStorage.uniId || ''
262 | });
263 |
264 | // place caret when last inserted row
265 | if (p === (pastedLines.length - 1)) {
266 | //console.log('place the damn caret', caretOffset);
267 | previousLine.focus();
268 |
269 | // if only one line was pasted add the caretOffset
270 | if (pastedLines.length === 1) {
271 | selection.collapse(previousLine.childNodes[0], (caretOffset + pastedLines[pastedLines.length - 1].length));
272 | } else {
273 | selection.collapse(previousLine.childNodes[0], pastedLines[pastedLines.length - 1].length);
274 | }
275 |
276 | //range.setStart(previousLine, 0);
277 | //range.collapse(true);
278 | }
279 | }
280 |
281 | this.clipboardData = {
282 | pasteLine: pasteLine.id,
283 | indexLine: [].indexOf.call(pasteLine.parentNode.children, pasteLine),
284 | lines: event.clipboardData.getData('text/plain').split('\n')
285 | };
286 | };
287 |
288 |
289 | // default tree empty lines
290 | realtimeEditor.prototype.insertDefault = function (target) {
291 | var div;
292 |
293 | target.innerHTML = '';
294 |
295 | for (var p = 0; p < 1; p++) {
296 | div = document.createElement('div');
297 |
298 | div.id = new Date().getTime() + '_' + Math.floor(Math.random() * (2000000 - 0)) + 0;
299 | div.innerHTML = ' ';
300 |
301 | target.appendChild(div);
302 | }
303 | };
304 |
305 | // Handle keydown
306 | realtimeEditor.prototype.keydown = function (event) {
307 | var textarea = event.target,
308 | timeId = new Date().getTime() + '_' + Math.floor(Math.random() * (2000000 - 0)) + 0,
309 | lines = textarea.getElementsByTagName('div'),
310 | div = document.createElement('div'),
311 | selection = window.getSelection(),
312 | activeLine;
313 |
314 | // reset paste boolean
315 | this.clipboardData = {};
316 | this.newLines = [];
317 |
318 | if (event.ctrlKey) {
319 | if (event.keyCode == 90) { // undo
320 | event.preventDefault();
321 |
322 | console.log('undo - not implemented yet');
323 | return;
324 | } else if (event.keyCode == 89) { // redo
325 | event.preventDefault();
326 |
327 | console.log('redo - not implemented yet');
328 | return;
329 | }
330 | }
331 |
332 | if (event.keyCode === 13) {
333 | // only adapt if Firefox on enter
334 | if (window.navigator.userAgent.indexOf('Firefox') > -1 || window.navigator.userAgent.indexOf('Edge') > -1) {
335 | if (selection.anchorNode.nodeType === 1) {
336 | activeLine = selection.anchorNode;
337 | } else {
338 | activeLine = selection.anchorNode.parentNode;
339 | }
340 |
341 | event.preventDefault();
342 |
343 | div.id = timeId;
344 |
345 | activeLine.parentNode.insertBefore(div, activeLine.nextSibling);
346 |
347 | div.focus();
348 |
349 | selection.collapse(div, 0);
350 | }
351 | }
352 |
353 | this.storedLines = [];
354 |
355 | for (var l = 0; l < lines.length; l++) {
356 | this.storedLines.push(lines[l].id);
357 | }
358 | };
359 |
360 | // Handle keyup
361 | realtimeEditor.prototype.keyup = function (event) {
362 | if (event.keyCode === 16 || event.keyCode === 17 || event.keyCode === 18) { // prevent shift, ctrl & alt default
363 | return false;
364 | }
365 |
366 | var textarea = event.target,
367 | lines = textarea.getElementsByTagName('div'),
368 | selection = window.getSelection(),
369 | range = selection.getRangeAt(0),
370 | timeId = new Date().getTime() + '_' + Math.floor(Math.random() * (2000000 - 0)) + 0,
371 | deletedLines = [],
372 | newLines = [],
373 | savedLines = [],
374 | newLineCounter = 0,
375 | data,
376 | currentIndex,
377 | previousLine,
378 | activeLine,
379 | type,
380 | lineExist;
381 |
382 | // check deleted lines
383 | if (lines.length < this.storedLines.length) {
384 | for (var s = 0; s < this.storedLines.length; s++) {
385 | lineExist = false;
386 |
387 | for (l = 0; l < lines.length; l++) {
388 | if (lines[l].id === this.storedLines[s]) {
389 | lineExist = true;
390 | }
391 | }
392 |
393 | if (lineExist === false) {
394 | deletedLines.push(this.storedLines[s]);
395 | }
396 | }
397 | }
398 |
399 | // get active line based on caret position
400 |
401 | if (window.getSelection().anchorNode.nodeType === 1) {
402 | activeLine = window.getSelection().anchorNode;
403 | } else {
404 | activeLine = window.getSelection().anchorNode.parentNode;
405 | }
406 |
407 | if (event.keyCode === 13) { // if enter
408 | if (activeLine.innerHTML === ' ') {
409 | type = 'newLine';
410 | } else {
411 | type = 'breakLine';
412 |
413 | if (activeLine.innerHTML === '') {
414 | activeLine.innerHTML = ' ';
415 | }
416 | }
417 |
418 | if (textarea.parentNode.classList.contains('is-dirty') === false) {
419 | textarea.parentNode.classList.add('is-dirty');
420 | }
421 | } else if (event.keyCode === 37 || event.keyCode === 38 || event.keyCode === 39 || event.keyCode === 40) { // if arrows
422 | type = 'moveCursor';
423 | timeId = activeLine.id;
424 | } else {
425 | type = 'modifyLine';
426 | timeId = activeLine.id;
427 |
428 | if (textarea.parentNode.classList.contains('is-dirty') === false) {
429 | textarea.parentNode.classList.add('is-dirty');
430 | }
431 | }
432 |
433 | currentIndex = [].indexOf.call(activeLine.parentNode.children, activeLine);
434 | previousLine = activeLine.previousSibling;
435 |
436 | if (currentIndex === 0) {
437 | previousLineId = 'firstLine';
438 | } else {
439 | previousLineId = previousLine.id;
440 | }
441 |
442 | activeLine.id = timeId;
443 |
444 | // Check and handle paste content
445 | if (this.clipboardData.lines !== undefined) {
446 | if (this.clipboardData.lines.length > 0) {
447 | type = 'pastedContent';
448 |
449 | //console.log('clipboardData', this.clipboardData);
450 | }
451 | }
452 |
453 | for (var l = 0; l < lines.length; l++) {
454 | savedLines.push({
455 | id: lines[l].id,
456 | text: lines[l].innerHTML,
457 | author: ''
458 | });
459 | }
460 |
461 | //console.log('savedLines', savedLines);
462 |
463 | data = {
464 | room: this.room,
465 | color: this.color,
466 | targetId: textarea.id,
467 | projectId: this.projectId,
468 | activeLineId: timeId,
469 | activeLineText: (activeLine.innerHTML === ' ' ? ' ' : activeLine.innerHTML.replace(/<\/?[^>]+(>|$)/g, '')),
470 | previousLineId: previousLineId,
471 | previousLineText: (type === 'breakLine' ? previousLine.innerHTML.replace(/<\/?[^>]+(>|$)/g, '') : undefined),
472 | type: type,
473 | author: this.author,
474 | indexLine: [].indexOf.call(activeLine.parentNode.children, activeLine),
475 | savedLines: savedLines,
476 | linesAmount: lines.length,
477 | deletedLines: deletedLines,
478 | newLines: this.newLines,
479 | caretPos: this.getCaret(),
480 | custom: this.custom
481 | };
482 |
483 | this.send(data);
484 | };
485 |
486 | realtimeEditor.prototype.deletedLines = function (data) {
487 | var textarea = document.getElementById(data.targetId),
488 | line;
489 |
490 | for (var i = data.deletedLines.length - 1; i >= 0; i--) {
491 | line = document.getElementById(data.deletedLines[i]);
492 |
493 | textarea.removeChild(line);
494 | }
495 | };
496 |
497 | // send
498 | realtimeEditor.prototype.send = function (data) {
499 | socket.emit('rtEditorSync', data, function (res) {
500 | //console.log('realtimeEditor res: ', res);
501 | });
502 | };
503 |
504 | // Patch & Update specific editor with changes
505 | realtimeEditor.prototype.update = function (data) {
506 | var target = document.getElementById(data.targetId),
507 | line = document.getElementById(data.activeLineId),
508 | dmp = new diff_match_patch(),
509 | loopedLine,
510 | previousLine,
511 | currentText,
512 | div,
513 | diff,
514 | patchText,
515 | resultText,
516 | data,
517 | currentTextIndex,
518 | previousLineIndex;
519 |
520 | if (target !== null) {
521 | /*for (var l = 0; l < this.text.length; l++) {
522 | line = this.text[l];
523 |
524 | // find line to patch
525 | if (line.id === data.activeLineId) {
526 | currentText = line.text;
527 | currentTextIndex = l;
528 | //console.log('found currentText to patch', data.activeLineText);
529 | }
530 |
531 | // find previous line if any
532 | if (line.id === data.previousLineId) {
533 | previousLine = line.text;
534 | previousLineIndex = l;
535 |
536 | //console.log('found previousLine to fix', data.previousLineText);
537 | }
538 | }*/
539 |
540 | if (data.type === 'modifyLine') { // patch existing line
541 | currentText = line.innerHTML;
542 |
543 | diff = dmp.diff_main(currentText, data.activeLineText);
544 | patchText = dmp.patch_make(currentText, data.activeLineText, diff);
545 | resultText = dmp.patch_apply(patchText, currentText);
546 |
547 | line.innerHTML = resultText[0];
548 |
549 | // data object
550 | //this.text[currentTextIndex].text = resultText[0];
551 | //this.text[currentTextIndex].author = data.author;
552 | } else if (data.type === 'newLine') { // add new line
553 | previousLine = document.getElementById(data.previousLineId);
554 | div = document.createElement('div');
555 | div.id = data.activeLineId;
556 | div.innerHTML = data.activeLineText;
557 |
558 | target.insertBefore(div, previousLine.nextSibling);
559 |
560 | // data object
561 |
562 | } else if (data.type === 'breakLine') {
563 | previousLine = document.getElementById(data.previousLineId);
564 | div = document.createElement('div');
565 | div.id = data.activeLineId;
566 | div.innerHTML = data.activeLineText;
567 |
568 | target.insertBefore(div, previousLine.nextSibling);
569 |
570 | diff = dmp.diff_main(previousLine.innerHTML, data.previousLineText);
571 | patchText = dmp.patch_make(previousLine.innerHTML, data.previousLineText, diff);
572 | resultText = dmp.patch_apply(patchText, previousLine.innerHTML);
573 |
574 | previousLine.innerHTML = (resultText[0] === '' ? ' ' : resultText[0]);
575 | } else if (data.type === 'pastedContent') {
576 | for (var n = 0; n < data.newLines.length; n++) {
577 | if (n === 0) {
578 | loopedLine = document.getElementById(data.newLines[n].id);
579 |
580 | // diff & patch first line of content
581 | diff = dmp.diff_main(loopedLine.innerHTML, data.newLines[n].text);
582 | patchText = dmp.patch_make(loopedLine.innerHTML, data.newLines[n].text, diff);
583 | resultText = dmp.patch_apply(patchText, loopedLine.innerHTML);
584 |
585 | loopedLine.innerHTML = resultText[0];
586 |
587 | previousLine = loopedLine;
588 | } else {
589 | div = document.createElement('div');
590 | div.id = data.newLines[n].id;
591 | div.innerHTML = data.newLines[n].text;
592 |
593 | target.insertBefore(div, previousLine.nextSibling);
594 |
595 | previousLine = div;
596 | }
597 | }
598 | }
599 |
600 |
601 | // Handle deleted lines
602 | if (data.deletedLines) {
603 | if (data.deletedLines.length > 0) {
604 | this.deletedLines(data);
605 | }
606 | }
607 |
608 |
609 | // move the recived data's user cursor
610 | if (data.type === 'clearCursor') {
611 | this.clearCursor(data);
612 | } else {
613 | if (document.getElementById(data.caretPos.activeLineId) !== null) {
614 | this.moveCursor(data);
615 | }
616 | }
617 |
618 | // move the client user cursor
619 | // only when data.type !== moveCursor to avoid infinite broadcast loop
620 | if (data.type !== 'moveCursor') {
621 | data = {
622 | room: this.room,
623 | color: this.color,
624 | targetId: this.id,
625 | projectId: this.projectId,
626 | type: 'moveCursor',
627 | author: this.author,
628 | savedLines: this.getLines(),
629 | caretPos: this.getCaret()
630 | };
631 |
632 | if (data.caretPos.activeLine !== undefined) {
633 | this.send(data);
634 | }
635 | }
636 | }
637 | };
638 |
639 | realtimeEditor.prototype.getLines = function (data) {
640 | var lines = this.editor.getElementsByTagName('div'),
641 | savedLines = [];
642 |
643 | for (var l = 0; l < lines.length; l++) {
644 | savedLines.push({
645 | id: lines[l].id,
646 | text: lines[l].innerHTML,
647 | author: ''
648 | });
649 | }
650 |
651 | return savedLines;
652 | };
653 |
654 | // move cursor
655 | // data properties required author, caretPos
656 | realtimeEditor.prototype.moveCursor = function (data) {
657 | var user = document.getElementById(data.author),
658 | target = document.getElementById(data.targetId),
659 | offsetText = data.savedLines[data.caretPos.lineIndex].text.substr(0, data.caretPos.offset),
660 | computedStyles = window.getComputedStyle(document.getElementById(data.caretPos.activeLineId), null),
661 | font = computedStyles.getPropertyValue('font-weight') + ' ' + computedStyles.getPropertyValue('font-size') + ' ' + computedStyles.getPropertyValue('font-family'),
662 | name;
663 |
664 | if (user === null && target !== null) {
665 | user = document.createElement('span');
666 | name = document.createElement('span');
667 |
668 | user.id = data.author;
669 | user.className = 'realtimeEditorUser';
670 | user.style.cssText = 'position: absolute; background-color: ' + data.color + '; width: 2px; height: 20px; top: 0; left: 0;';
671 | user.style.top = (data.caretPos.lineIndex * this.lineHeight) + 'px';
672 | user.style.left = this.getTextWidth(offsetText, font) + 'px';
673 | user.contentEditable = false;
674 |
675 | name.style.cssText = 'position: absolute; opacity: 0; transition: 0.2s ease; visibility: hidden; min-width: ' + (this.getTextWidth(data.authorName, 'normal 10px Roboto, Helvetica, Arial') + 5) + 'px; z-index: 1; font-size: 10px; background-color: ' + data.color + '; color: #FFF !important; height: 15px; line-height: 15px; padding-left: 5px; bottom: 20px';
676 | name.innerHTML = data.authorName;
677 |
678 | user.appendChild(name);
679 | target.appendChild(user);
680 |
681 | user.addEventListener('mouseenter', this.showName, false);
682 | user.addEventListener('mouseleave', this.hideName, false);
683 | } else {
684 | user.style.top = (data.caretPos.lineIndex * this.lineHeight) + 'px';
685 | user.style.left = this.getTextWidth(offsetText, font) + 'px';
686 | }
687 | };
688 |
689 | // get the width of the text to calculate in pixel the caret position
690 | realtimeEditor.prototype.getTextWidth = function (text, font) {
691 | var canvas = this.getTextWidth.canvas || (this.getTextWidth.canvas = document.createElement("canvas")), // re-use canvas object for better performance
692 | context = canvas.getContext("2d"),
693 | metrics;
694 |
695 | context.font = font;
696 |
697 | metrics = context.measureText(text);
698 |
699 | return Math.floor(metrics.width);
700 | };
701 |
702 | // remove the user color node
703 | realtimeEditor.prototype.clearCursor = function (data) {
704 | var user = document.getElementById(data.author);
705 |
706 | if (user !== null) {
707 | user.parentNode.removeChild(user);
708 | }
709 | };
710 |
711 | // show caret user name
712 | realtimeEditor.prototype.showName = function (event) {
713 | var name = this.getElementsByTagName('span')[0];
714 |
715 | name.style.opacity = 1;
716 | name.style.visibility = 'visible';
717 | };
718 |
719 | realtimeEditor.prototype.hideName = function (event) {
720 | var name = this.getElementsByTagName('span')[0];
721 |
722 | name.style.opacity = 0;
723 | name.style.visibility = 'hidden';
724 | };
725 |
726 | // toggle message upon disconnect
727 | realtimeEditor.prototype.toggleMessage = function (action) {
728 | var message = document.getElementById('rtEditor_' + this.id),
729 | div;
730 |
731 | if (message === null) {
732 | div = document.createElement('div');
733 |
734 | div.id = 'rtEditor_' + this.id;
735 | div.style.font = 'italic 14px Roboto, Helvetica, Arial';
736 | div.innerHTML = this.message;
737 | }
738 |
739 | if (action === 'show') {
740 | this.editor.parentNode.appendChild(div);
741 | } else {
742 | if (message !== null) {
743 | message.parentNode.removeChild(message);
744 | }
745 | }
746 | };
747 |
748 | realtimeEditor.prototype.exit = function (room, callback) {
749 | if (socket) {
750 | socket.emit('rtEditorExit', {room: room}, function (res) {
751 | if (callback !== undefined) {
752 | callback(res);
753 | }
754 | });
755 | } else {
756 | console.error('realtimeEditor: cant leave room, socket.io not detected');
757 | }
758 | };
--------------------------------------------------------------------------------