codiad/plugins/Codiad-Collaborative-master/init.js

647 lines
26 KiB
JavaScript
Executable File

/*
* 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 '<div id="selection-' + username + '" class="collaborative-selection-wrapper">' +
'<div class="collaborative-selection"></div>' +
'<div class="collaborative-selection-tooltip">' + username + '</div>' +
'</div>';
},
/* 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);