/* * Copyright (c) Codiad (codiad.com) & Florent Galland & Luc Verdier, * distributed as-is and without warranty under the MIT License. See * [root]/license.txt for more. This information must remain intact. */ (function (global, $) { var codiad = global.codiad, scripts= document.getElementsByTagName('script'), path = scripts[scripts.length-1].src.split('?')[0], curpath = path.split('/').slice(0, -1).join('/')+'/'; /* FIXME Dynamically load diff match patch lib. Is there any better way? */ $.getScript('lib/diff_match_patch.js'); var codiad = global.codiad; $(function () { if (typeof(codiad.collaborative) == 'undefined') { codiad.collab.init(); } else { codiad.modal.load(400, curpath+"dialog.php?action=warn"); } }); ////////////////////////////////////////////////////////////////// // // Collaborative Component for Codiad // --------------------------------- // Displays in real time the selection position and // the changes when concurrently editing files. // ////////////////////////////////////////////////////////////////// codiad.collab = { controller: curpath + 'controller.php', /* The filename of the file to wich we are currently registered as a * collaborator. Might be null if we are not collaborating to any file. */ currentFilename: null, /* Store the text shadows for every edited files. * {'filename': shadowString, ... } */ shadows: {}, /* Store the currently displayed usernames and their corresponding * current selection. * [username: {start: {row: 12, column: 14}, end: {row: 14, column: 19}}, ... ] */ displayedSelections: [], /* Time interval in milisecond to send an heartbeat to the server. */ heartbeatInterval: 5000, /* Status of the collaboration logic. */ enableCollaboration: false, init: function () { var _this = this; /* Make sure to start clean by unregistering from any file first. */ this.unregisterAsCollaboratorFromAllFiles(); this.removeSelectionAndChangesForAllFiles(); /* TODO For debug only, remove this for production. */ //this.removeServerTextForAllFiles(); this.$onSelectionChange = this.onSelectionChange.bind(this); this.$onChange = this.onChange.bind(this); this.$postSelectionChange = this.postSelectionChange.bind(this); this.$updateCollaboratorsSelections = this.updateCollaboratorsSelections.bind(this); this.$displaySelections = this.displaySelections.bind(this); this.$synchronizeText = this.synchronizeText.bind(this); this.$postSynchronizeText = this.postSynchronizeText.bind(this); this.$sendHeartbeat = this.sendHeartbeat.bind(this); /* Subscribe to know when a file is being closed. */ amplify.subscribe('active.onClose', function (path) { if (_this.currentFilename === path) { _this.unregisterAsCollaboratorOfCurrentFile(); _this.removeAllSelections(); } }); /* Subscribe to know when a file become active. */ amplify.subscribe('active.onFocus', function (path) { _this.unregisterAsCollaboratorOfCurrentFile(); _this.registerAsCollaboratorOfActiveFile(); /* Create the initial shadow for the current file. */ _this.shadows[_this.currentFilename] = _this._getCurrentFileText(); _this.sendAsShadow(_this.currentFilename, _this.shadows[_this.currentFilename]); _this.addListeners(); }); /* Start to send an heartbeat to notify the server that we are * alive. */ setInterval(this.$sendHeartbeat, this.heartbeatInterval); /* Start the collaboration logic. */ this.setCollaborationStatus(true); $(".collaborative-selection,.collaborative-selection-tooltip").live({ mouseenter: function () { var markup = $(this).parent(); _this.showTooltipForMarkup(markup); }, mouseleave: function () { var markup = $(this).parent(); _this.showTooltipForMarkup(markup, 500); } }); }, /* Start or stop the collaboration logic. */ setCollaborationStatus: function (enableCollaboration) { /* Some static variables to hold the setInterval reference. */ if (typeof this.setCollaborationStatus.updateSelectionsIntervalRef === 'undefined') { this.setCollaborationStatus.updateSelectionsIntervalRef = null; } if (typeof this.setCollaborationStatus.synchronizeTextIntervalRef === 'undefined') { this.setCollaborationStatus.synchronizeTextIntervalRef = null; } if (enableCollaboration && !this.enableCollaboration) { console.log('Starting collaboration logic.'); this.enableCollaboration = true; /* Start to ask periodically for the potential other collaborators * selection. */ this.setCollaborationStatus.updateSelectionsIntervalRef = setInterval(this.$updateCollaboratorsSelections, 500); /* Start to ask periodically for the potential other collaborators * changes. */ this.setCollaborationStatus.synchronizeTextIntervalRef = setInterval(this.$synchronizeText, 500); /* Sync right away for responsiveness - there's often already a * server copy of the collab file */ this.$synchronizeText(); } else if (!enableCollaboration && this.enableCollaboration) { console.log('Stopping collaboration logic.'); this.enableCollaboration = false; clearInterval(this.setCollaborationStatus.updateSelectionsIntervalRef); clearInterval(this.setCollaborationStatus.synchronizeTextIntervalRef); this.removeAllSelections(); } }, removeSelectionAndChangesForAllFiles: function () { $.post(this.controller, { action: 'removeSelectionAndChangesForAllFiles' }, function (data) { // console.log('complete unregistering from all'); // console.log(data); codiad.jsend.parse(data); }); }, removeServerTextForAllFiles: function () { $.post(this.controller, { action: 'removeServerTextForAllFiles' }, function (data) { // console.log('complete unregistering from all'); // console.log(data); codiad.jsend.parse(data); }); }, unregisterAsCollaboratorFromAllFiles: function () { $.post(this.controller, { action: 'unregisterFromAllFiles' }, function (data) { // console.log('complete unregistering from all'); // console.log(data); codiad.jsend.parse(data); }); }, registerAsCollaboratorOfActiveFile: function () { var filename = codiad.active.getPath(); this.currentFilename = filename; $.post(this.controller, { action: 'registerToFile', filename: filename }, function (data) { // console.log('complete registering'); // console.log(data); codiad.jsend.parse(data); }); }, unregisterAsCollaboratorOfCurrentFile: function () { // console.log('unregister ' + this.currentFilename); if (this.currentFilename !== null) { $.post(this.controller, { action: 'unregisterFromFile', filename: this.currentFilename }, function (data) { // console.log('complete unregistering'); // console.log(data); codiad.jsend.parse(data); }); this.currentFilename = null; } }, sendHeartbeat: function () { var _this = this; $.post(this.controller, { action: 'sendHeartbeat' }, function (data) { /* The data returned by the server contains the number * of connected collaborators. */ data = codiad.jsend.parse(data); if (data.collaboratorCount > 1) { /* Someone else is connected, start the * collaboration logic. */ _this.setCollaborationStatus(true); } else { /* We are the only conected user, stop the * collaboration logic. */ _this.setCollaborationStatus(false); } }); }, /* Add appropriate listeners to the current EditSession. */ addListeners: function () { this.addListenerToOnSelectionChange(); this.addListenerToOnChange(); }, /* Remove listeners from the current EditSession. */ removeListeners: function () { this.removeListenerToOnSelectionChange(); this.removeListenerToOnChange(); }, addListenerToOnSelectionChange: function () { var selection = this._getSelection(); selection.addEventListener('changeCursor', this.$onSelectionChange); selection.addEventListener('changeSelection', this.$onSelectionChange); }, removeListenerToOnSelectionChange: function () { var selection = this._getSelection(); selection.removeEventListener('changeCursor', this.$onSelectionChange); selection.removeEventListener('changeSelection', this.$onSelectionChange); }, addListenerToOnChange: function () { var editor = this._getEditor(); editor.addEventListener('change', this.$onChange); }, removeListenerToOnChange: function () { var editor = this._getEditor(); editor.removeEventListener('change', this.$onChange); }, onChange: function(e) { this.$synchronizeText(); }, /* Throttling mechanism for postSelectionChange */ onSelectionChange: function (e) { var minInterval = 250; var now = new Date().getTime(); if (typeof(this.onSelectionChange.lastSelectionChange) === 'undefined') { this.onSelectionChange.lastSelectionChange = now; } var interval = now - this.onSelectionChange.lastSelectionChange; this.onSelectionChange.lastSelectionChange = now; if (interval < minInterval) { var intervalDifference = minInterval - interval; clearTimeout(this.onSelectionChange.deferredPost); this.onSelectionChange.deferredPost = setTimeout(this.$postSelectionChange, intervalDifference); } else { this.$postSelectionChange(); } }, postSelectionChange: function () { var post = { action: 'sendSelectionChange', filename: codiad.active.getPath(), selection: JSON.stringify(this._getSelection().getRange()) }; $.post(this.controller, post, function (data) { // console.log('complete selection change'); // console.log(data); codiad.jsend.parse(data); }); }, /* Request the server for the collaborators selections for the current * file. */ updateCollaboratorsSelections: function () { var _this = this; if (this.currentFilename !== null) { $.post(this.controller, { action: 'getUsersAndSelectionsForFile', filename: this.currentFilename }, function (data) { // console.log('complete getUsersAndSelectionsForFile'); // console.log(data); var selections = codiad.jsend.parse(data); _this.$displaySelections(selections); /* The server returned the selections for the * currently active users. If a user which is no * more active has a visible selection, remove it. */ if (_this.displayedSelections !== null) { for (var username in _this.displayedSelections) { if (_this.displayedSelections.hasOwnProperty(username)) { if (selections === null || !(username in selections)) { _this.removeSelection(username); } } } } _this.displayedSelections = selections; }); } }, /* Displays a selection in the current file for the given user. * The expected selection object is compatible with what is returned * from the getUsersAndSelectionsForFile action on the server * controller. * Selection object example: * {username: {start: {row: 12, column: 14}, end: {row: 14, column: 19}}} */ displaySelections: function (selections) { // console.log('displaySelection'); for (var username in selections) { if (selections.hasOwnProperty(username)) { var markup = $('#selection-' + username); if (markup.length === 0) { /* The markup for the selection of this user does not * exist yet. Append it to the dom. */ markup = $(this.getSelectionMarkupForUser(username)); $('body').append(markup); } var screenCoordinates = this._getEditor().renderer .textToScreenCoordinates(selections[username].selection.start.row, selections[username].selection.start.column); /* Check if the selection has changed. */ if (markup.css('left').slice(0, -2) !== String(screenCoordinates.pageX) || markup.css('top').slice(0, -2) !== String(screenCoordinates.pageY)) { markup.css({ left: screenCoordinates.pageX, top: screenCoordinates.pageY }); markup.children('.collaborative-selection').css('background-color', selections[username].color); markup.children('.collaborative-selection-tooltip').css('background-color', selections[username].color); this.showTooltipForMarkup(markup, 2000); } } } }, /* Show the tooltip of the given markup. If duration is defined, * the tooltip is automaticaly hidden when the time is elapsed. */ showTooltipForMarkup: function (markup, duration) { var timeoutRef = markup.attr('hideTooltipTimeoutRef'); if (timeoutRef !== undefined) { clearTimeout(timeoutRef); markup.removeAttr('hideTooltipTimeoutRef'); } markup.children('.collaborative-selection-tooltip').fadeIn('fast'); if (duration !== undefined) { timeoutRef = setTimeout(this._hideTooltipAndRemoveAttrForBoundMarkup.bind(markup), duration); markup.attr('hideTooltipTimeoutRef', timeoutRef); } }, /* This function must be bound with the markup which contains * the tooltip to hide. */ _hideTooltipAndRemoveAttrForBoundMarkup: function () { this.children('.collaborative-selection-tooltip').fadeOut('fast'); this.removeAttr('hideTooltipTimeoutRef'); }, /* Remove the selection corresponding to the given username. */ removeSelection: function (username) { console.log('remove ' + username); $('#selection-' + username).remove(); delete this.displayedSelections[username]; }, /* Remove all the visible selections. */ removeAllSelections: function () { if (this.displayedSelections !== null) { for (var username in this.displayedSelections) { if (this.displayedSelections.hasOwnProperty(username)) { this.removeSelection(username); } } } }, /* Throttling mechanism for postSynchronizeText */ synchronizeText: function () { var _this = this; var now = new Date().getTime(); var minInterval = 350; if (typeof(this.synchronizeText.lastRun) === 'undefined') { this.synchronizeText.lastRun = now; } var interval = now - this.synchronizeText.lastRun; var _this = this; var successCallback = function() { _this.synchronizeText.lastRun = now; _this.$postSynchronizeText(); }; clearTimeout(this.synchronizeText.deferredPost); if (interval < minInterval) { var intervalDifference = minInterval - interval; this.synchronizeText.deferredPost = setTimeout(successCallback, intervalDifference); } else { successCallback(); } }, /* Make a diff of the current file text with the shadow and send it to * the server. */ postSynchronizeText: function () { var _this = this; var currentFilename = this.currentFilename; /* Do not send any request if no file is focused. */ if (currentFilename === null) { return; } /* Save the current text state, because it can be modified by the * user on the UI thread. */ var currentText = this._getCurrentFileText(); /* Make a diff between the current text and the previously saved * shadow. */ codiad.workerManager.addTask({ taskType: 'diff', id: 'collaborative_' + currentFilename, original: _this.shadows[currentFilename], changed: currentText }, function (success, patch) { if (success) { /* Send our edits to the server, and get in response a * patch of the edits in the server text. */ // console.log(patch); _this.shadows[currentFilename] = currentText; var post = { action: 'synchronizeText', filename: currentFilename, patch: patch }; // console.log(post); $.post(this.controller, post, function (data) { // console.log('complete synchronizeText'); // console.log(data); var patchFromServer = codiad.jsend.parse(data); if (patchFromServer === 'error') { return; } // console.log(patchFromServer); /* Apply the patch from the server text to the shadow * and the current text. */ var dmp = new diff_match_patch(); var patchedShadow = dmp.patch_apply(dmp.patch_fromText(patchFromServer), _this.shadows[currentFilename]); // console.log(patchedShadow); _this.shadows[currentFilename] = patchedShadow[0]; /* Update the current text. */ currentText = _this._getCurrentFileText(); var patchedCurrentText = dmp.patch_apply(dmp.patch_fromText(patchFromServer), currentText)[0]; var diff = dmp.diff_main(currentText, patchedCurrentText); var deltas = _this.diffToAceDeltas(diff, currentText); _this._getDocument().applyDeltas(deltas); }); } else { console.log('problem diffing'); console.log(patch); } }, this); }, /* Send the string 'shadow' as server shadow for 'filename'. */ sendAsShadow: function (filename, shadow) { $.post(this.controller, { action: 'sendShadow', filename: filename, shadow: shadow }, function (data) { // console.log('complete sendShadow'); // console.log(data); codiad.jsend.parse(data); }); }, /* Helper method that return a Ace editor delta change from a * diff_match_patch diff object and the original text that was * used to compute the diff. */ diffToAceDeltas: function (diff, originalText) { var dmp = new diff_match_patch(); var deltas = dmp.diff_toDelta(diff).split('\t'); /* * Code deeply inspired by chaoscollective / Space_Editor */ var offset = 0; var row = 1; var col = 1; var aceDeltas = []; var aceDelta = {}; for (var i = 0; i < deltas.length; ++i) { var type = deltas[i].charAt(0); var data = decodeURI(deltas[i].substring(1)); switch (type) { case "=": /* The new text is equal to the original text for a * number of characters. */ var unchangedCharactersCount = parseInt(data, 10); for (var j = 0; j < unchangedCharactersCount; ++j) { if (originalText.charAt(offset + j) == "\n") { ++row; col = 1; } else { col++; } } offset += unchangedCharactersCount; break; case "+": /* Some characters were added. */ var startRow = row; var startCol = col; var innerRows = data.split("\n"); var innerRowsCount = innerRows.length - 1; row += innerRowsCount; if (innerRowsCount <= 0) { col += data.length; } else { col = innerRows[innerRowsCount].length + 1; } aceDelta = { action: "insert", start: {row: (startRow - 1), column: (startCol - 1)}, end: {row: (row - 1), column: (col - 1)}, lines: innerRows }; aceDeltas.push(aceDelta); break; case "-": /* Some characters were subtracted. */ var deletedCharactersCount = parseInt(data, 10); var removedData = originalText.substring(offset, offset + deletedCharactersCount); var removedRows = removedData.split("\n"); var removedRowsCount = removedRows.length - 1; var endRow = row + removedRowsCount; var endCol = col; if (removedRowsCount <= 0) { endCol = col + deletedCharactersCount; } else { endCol = removedRows[removedRowsCount].length + 1; } aceDelta = { action: "remove", start: {row: (row - 1), column: (col - 1)}, end: {row: (endRow - 1), column: (endCol - 1)}, lines: removedRows }; aceDeltas.push(aceDelta); offset += deletedCharactersCount; break; default: /* Return an innofensive empty list of Ace deltas. */ console.log("Unhandled case '" + type + "' while building Ace deltas."); return []; } } return aceDeltas; }, getSelectionMarkupForUser: function (username) { return '
' + '
' + '
' + username + '
' + '
'; }, /* Set of helper methods to manipulate the editor. */ _getEditor: function () { return codiad.editor.getActive(); }, _getEditSession: function () { return codiad.editor.getActive().getSession(); }, _getSelection: function () { return codiad.editor.getActive().getSelection(); }, _getDocument: function () { return codiad.editor.getActive().getSession().getDocument(); }, _getCurrentFileText: function () { return codiad.editor.getActive().getSession().getValue(); } }; })(this, jQuery);