| 1 |
// globals
|
| 2 |
|
| 3 |
// The saved content we use to do the comparisons with the new content typed. We also update this variable when we store the new content returned
|
| 4 |
// from the server in the textarea
|
| 5 |
var oldContent = '';
|
| 6 |
|
| 7 |
// The same as above but used to store the revision id returned from the server. This will be used to send it in the next call
|
| 8 |
var lastRevision = '';
|
| 9 |
|
| 10 |
// This variable is updated before and after the asynchronous call using checkChanges function. If the values match then it
|
| 11 |
// means the user hasnt typed new content or he has stopped typing
|
| 12 |
var newChange = 0;
|
| 13 |
|
| 14 |
// The last content the client knows about used to set the newChange variable
|
| 15 |
var lastCheckedContent = '';
|
| 16 |
|
| 17 |
// If set to true we dont do the asynchronous call. This way the new content isn't updated unless the users stops typing. Therefore there are neither jumps of
|
| 18 |
// caret when the content is updated nor missed typed content during the call.
|
| 19 |
// Note: maybe a timeout needs to be set in case of the user never stops typing (?) and a lot of incoming changes are queuing to be update
|
| 20 |
var userIsTyping = false;
|
| 21 |
|
| 22 |
// If set to false we dont get the value from the server (only in use case 2) since the last content and revision id we uploaded are supposed to match
|
| 23 |
// with those ones in the server
|
| 24 |
var isDiff = false;
|
| 25 |
|
| 26 |
// If set to true the user clicks on submit button so no warnings of unsaved messages are required
|
| 27 |
var userSubmit = false;
|
| 28 |
|
| 29 |
// the ce content id
|
| 30 |
var cId = '';
|
| 31 |
|
| 32 |
// these are only used for debugging
|
| 33 |
var debughash = '';
|
| 34 |
var phase = '';
|
| 35 |
var newchangetest1 = 0;
|
| 36 |
var newchangetest2 = 0;
|
| 37 |
var phasejs = 0;
|
| 38 |
var usersdebug = '';
|
| 39 |
|
| 40 |
if (isJsEnabled()) {
|
| 41 |
addLoadEvent(start);
|
| 42 |
}
|
| 43 |
|
| 44 |
function start()
|
| 45 |
{
|
| 46 |
// set the handler to all submit buttons of the form. The aim is to have the same version in the ce_content table
|
| 47 |
var i = 0, el, els = document.getElementsByTagName("input");
|
| 48 |
// iterates each input element
|
| 49 |
while (el = els[i++]) {
|
| 50 |
if (el.getAttribute('type') == 'submit') {
|
| 51 |
// Note: leaveEditor(); or updateDocument(param); will NOT work here.
|
| 52 |
// Must be a reference to a function name, not a function call
|
| 53 |
el.onclick = leaveEditor;
|
| 54 |
}
|
| 55 |
}
|
| 56 |
|
| 57 |
var textArea = getTextArea();
|
| 58 |
// the common content overwrittes any other content when we start
|
| 59 |
var initContent = document.getElementById('edit-init').value;
|
| 60 |
if (initContent != '')
|
| 61 |
textArea.value = initContent;
|
| 62 |
// Get the initial values
|
| 63 |
// These functions will be simple with jQuery
|
| 64 |
oldContent = textArea.value;
|
| 65 |
lastCheckedContent = oldContent;
|
| 66 |
// we have to take the content id generated by the server from the initial form
|
| 67 |
cId = document.getElementById('edit-cid').value;
|
| 68 |
// we get the initial revision id from the hidden form field of the initial page
|
| 69 |
lastRevision = document.getElementById('edit-rev').value;
|
| 70 |
|
| 71 |
|
| 72 |
// we want to be sure the user doesnt exit the document without saving by mistake
|
| 73 |
// since our site is more a web app than a simple web site consider onbeforeunload morally right
|
| 74 |
if (cId != '') // only valid in the collaborative editor section
|
| 75 |
window.onbeforeunload = checkBeforeExit; // onbeforeunload handles better the messages than onunload
|
| 76 |
|
| 77 |
// this function is available in drupal.collaborative_editor_ui.js
|
| 78 |
addVisualElements();
|
| 79 |
// Activate asyncronous refresh loop
|
| 80 |
activateHeartBeat();
|
| 81 |
}
|
| 82 |
|
| 83 |
|
| 84 |
|
| 85 |
// Save contents before exit so the content in original table matches with the one in ce_content
|
| 86 |
function leaveEditor() {
|
| 87 |
userSubmit = true;
|
| 88 |
updateDocument(true); // true to avoid collision messages in this update. However we will need to log this error in some way
|
| 89 |
// Note: Possible bug, if we leave blank title the returned use case is 4 despite of the correct behaviour
|
| 90 |
}
|
| 91 |
|
| 92 |
|
| 93 |
// check if there are new changes. If any, ask user before exit
|
| 94 |
function checkBeforeExit() {
|
| 95 |
if (newChange != checkChanges() && !userSubmit) // false if the user clicks the submit button
|
| 96 |
return "You have unsaved changes. Click Submit to save them. Otherwise click OK to exit";
|
| 97 |
}
|
| 98 |
|
| 99 |
|
| 100 |
|
| 101 |
// get the textarea element
|
| 102 |
function getTextArea()
|
| 103 |
{
|
| 104 |
// we get the first textarea of the form. This need to be more general
|
| 105 |
var tas = document.getElementsByTagName("textarea");
|
| 106 |
return tas[0];
|
| 107 |
}
|
| 108 |
|
| 109 |
|
| 110 |
// Activate asyncronous refresh loop
|
| 111 |
// There are a lot of parameters to include into this function. Since the frequency of refresh ought to be different
|
| 112 |
// if a user is editing alone, is not typing, there has been an error, etc
|
| 113 |
// For now, it is set to 8 seconds for testing purposes
|
| 114 |
function activateHeartBeat()
|
| 115 |
{
|
| 116 |
// If user is typing we dont interfare and we'll do the update later on
|
| 117 |
if (!userIsTyping)
|
| 118 |
updateDocument(false);
|
| 119 |
|
| 120 |
// We set the update every 8 seconds
|
| 121 |
setTimeout(function () { activateHeartBeat(); }, 8000);
|
| 122 |
}
|
| 123 |
|
| 124 |
|
| 125 |
// a cross browser function to get the start and end of user caret
|
| 126 |
// Not tested in safari or OSX browsers
|
| 127 |
function getCaretPosition()
|
| 128 |
{
|
| 129 |
var textArea = getTextArea();
|
| 130 |
var start = 0;
|
| 131 |
var end = 0;
|
| 132 |
// a bit weird but we need the range object to set the caret in setCaretPosition() for IE
|
| 133 |
var rangeCopy = null;
|
| 134 |
|
| 135 |
if(document.getSelection) // FF
|
| 136 |
{
|
| 137 |
start = textArea.selectionStart;
|
| 138 |
end = textArea.selectionEnd;
|
| 139 |
}
|
| 140 |
else if(document.selection) // IE
|
| 141 |
{
|
| 142 |
// The current selection
|
| 143 |
var range = document.selection.createRange();
|
| 144 |
rangeCopy = range.duplicate();
|
| 145 |
// Select all text
|
| 146 |
rangeCopy.moveToElementText(textArea);
|
| 147 |
// Now move 'dummy' end point to end point of original range
|
| 148 |
rangeCopy.setEndPoint( 'EndToEnd', range );
|
| 149 |
// Now we can calculate start and end points
|
| 150 |
start = rangeCopy.text.length - range.text.length;
|
| 151 |
end = start + range.text.length;
|
| 152 |
}
|
| 153 |
return {start: start, end: end, rangeCopy: rangeCopy};
|
| 154 |
}
|
| 155 |
|
| 156 |
|
| 157 |
// We set the caret position, even in selection cases, with the values returned by getCaretPosition()
|
| 158 |
function setCaretPosition(start,end,rangeCopy)
|
| 159 |
{
|
| 160 |
var textArea = getTextArea();
|
| 161 |
if(document.getSelection) // FF
|
| 162 |
{
|
| 163 |
// what a pleasure in FF ;)
|
| 164 |
textArea.setSelectionRange(start,end);
|
| 165 |
}
|
| 166 |
else if(document.selection) // IE
|
| 167 |
{
|
| 168 |
rangeCopy.collapse(true);
|
| 169 |
rangeCopy.moveStart("character",start);
|
| 170 |
rangeCopy.moveEnd("character",end-start);
|
| 171 |
rangeCopy.select();
|
| 172 |
}
|
| 173 |
}
|
| 174 |
|
| 175 |
|
| 176 |
// The values to post are set and send to the server using XMLHTTPRequest
|
| 177 |
function updateDocument(exitDocument)
|
| 178 |
{
|
| 179 |
// we will send the parameters stored in this array and passed as third parameter of HTTPPost
|
| 180 |
var csend = [];
|
| 181 |
// This textArea should content the current value
|
| 182 |
var textArea = getTextArea();
|
| 183 |
var currentContent = textArea.value;
|
| 184 |
|
| 185 |
// Bug http://drupal.org/node/78553 fixed. We transform all carriage returns to \r\n before calculating the delta
|
| 186 |
currentContent = currentContent.replace(/\r/g,"").replace(/\n/g,"\r\n");
|
| 187 |
|
| 188 |
if (lastRevision != 0) {
|
| 189 |
|
| 190 |
// We get the difference block of the two strings (initial content and current content) and its parameters
|
| 191 |
// In this comparison the oldContent is the last version we got from the server. The currentContent is what we currently have in the textarea
|
| 192 |
var diffResult = getDiffBlock(oldContent, currentContent);
|
| 193 |
|
| 194 |
// set post vars to send to the server
|
| 195 |
csend['prefixLength'] = diffResult.prefixLength; // the previous common part of the two strings
|
| 196 |
csend['suffixLength'] = diffResult.suffixLength; // this is not used in the server. Could be deleted (not sent) in next versions
|
| 197 |
csend['replacementLength'] = diffResult.replacementLength; // it is 0 if there is no replacement
|
| 198 |
csend['diffText'] = diffResult.diffText; // the difference returned in the comparison
|
| 199 |
csend['clientRev'] = lastRevision; // the revision we send to the server. Usually is the same returned last time from the server
|
| 200 |
|
| 201 |
// set isDiff if nothing is sent. This will be used in handleresponse for use case 2
|
| 202 |
isDiff = (diffResult.diffText != "" && diffResult.replacementLength != 0) ? true : false;
|
| 203 |
}
|
| 204 |
else {
|
| 205 |
csend['diffText'] = currentContent;
|
| 206 |
csend['clientRev'] = lastRevision;
|
| 207 |
isDiff = true;
|
| 208 |
}
|
| 209 |
|
| 210 |
// write values into the debug field
|
| 211 |
if (diffResult) {
|
| 212 |
document.getElementById('edit-debug').value = "old text : " + oldContent
|
| 213 |
+ "\ncurrent text : " + currentContent
|
| 214 |
+ "\ndiff:" + diffResult.diffText
|
| 215 |
+ " , diff length:" + diffResult.diffText.length
|
| 216 |
+ "\nold-length:" + oldContent.length
|
| 217 |
+ "\nrep:" + diffResult.replacementLength // three numeric values from de getDiffBlock()
|
| 218 |
+ " pre:" + diffResult.prefixLength
|
| 219 |
+ " suf:" + diffResult.suffixLength
|
| 220 |
+ "\nrevisionid:" + lastRevision
|
| 221 |
// + "\nusersdebug: " + usersdebug.length
|
| 222 |
+ "\nprevious phase: " + phase // notice it is the previous phase
|
| 223 |
+ "\nold/new change flag: " + newchangetest1 // this values are returned by checkChanges()
|
| 224 |
+ ":" + newchangetest2
|
| 225 |
+ "\nDebughash: " + debughash; // additional debug vars
|
| 226 |
|
| 227 |
}
|
| 228 |
|
| 229 |
// ...ui.js
|
| 230 |
showSaveMessage();
|
| 231 |
|
| 232 |
// record the changes done so far so that we compare if it matches after the asynchronous call is done
|
| 233 |
newChange = checkChanges();
|
| 234 |
//debug var
|
| 235 |
newchangetest1 = newChange;
|
| 236 |
|
| 237 |
var cparameter = '';
|
| 238 |
var cback = function (r,s,t){handleResponse(r, currentContent, newChange, exitDocument);}; // drupal.js: callbackFunction(xmlHttp.responseText, xmlHttp, callbackParameter);
|
| 239 |
HTTPPost("collaborative_editor/" + cId + "/getAsyncContent", cback, cparameter, csend); // csend is the array with the parameters
|
| 240 |
}
|
| 241 |
|
| 242 |
|
| 243 |
|
| 244 |
// we use the response from the server to update the textarea
|
| 245 |
function handleResponse(r, contentUploaded, newChangeUploaded, exitDocument)
|
| 246 |
{
|
| 247 |
// check async errors
|
| 248 |
// (...)
|
| 249 |
|
| 250 |
// parse the object from server and get each value
|
| 251 |
var response = parseJson(r);
|
| 252 |
var textArea = getTextArea();
|
| 253 |
var clientContent = textArea.value;
|
| 254 |
|
| 255 |
var userList = response.currentusers;
|
| 256 |
|
| 257 |
userIsTyping = false;
|
| 258 |
|
| 259 |
// When there is new content from the server
|
| 260 |
// This will be only executed if the user is not typing. newChangeUploaded is the state of the textarea before the asynchronous call
|
| 261 |
// if it doesnt match with the current test returned by checkChanges it means the users has typed something new
|
| 262 |
if (response.return_rev != "" && newChangeUploaded == checkChanges())
|
| 263 |
{
|
| 264 |
//debug var
|
| 265 |
newchangetest2 = checkChanges();
|
| 266 |
// when there is something from others. cases 3, 4, 5. use case 1 is the exception
|
| 267 |
if (response.otherschange == 1) {
|
| 268 |
//debug vars
|
| 269 |
phasejs = 1;
|
| 270 |
|
| 271 |
// decode content to avoid errors
|
| 272 |
var serverContent = decodeURIComponent(response.content);
|
| 273 |
lastRevision = response.return_rev; // revision returned from the server. We will send it in our next call
|
| 274 |
|
| 275 |
var oldCaretPos = getCaretPosition(); // we get the current caret position of the user before updating the content of the textarea
|
| 276 |
var diffText = getDiffBlock(clientContent, serverContent); // get the difference to see where the new content starts
|
| 277 |
var changeLength = serverContent.length - clientContent.length; // if positive there is new content, otherwise something has been deleted
|
| 278 |
|
| 279 |
// we update the variables with the content from the server
|
| 280 |
oldContent = serverContent;
|
| 281 |
lastCheckedContent = oldContent;
|
| 282 |
textArea.value = serverContent; // the content of the textarea is updated
|
| 283 |
|
| 284 |
// the caret is set again to mantain the caret position in the same place
|
| 285 |
var newContentStart = diffText.prefixLength;
|
| 286 |
var newCaretPosStart = oldCaretPos.start;
|
| 287 |
var newCaretPosEnd = oldCaretPos.end;
|
| 288 |
// the offset we must add is the length of the new content if it is before our cursor position
|
| 289 |
if (newCaretPosStart > newContentStart)
|
| 290 |
newCaretPosStart += changeLength;
|
| 291 |
// if we have a selection done and the content is in between the selection will increase as well
|
| 292 |
if (newCaretPosEnd > newContentStart)
|
| 293 |
newCaretPosEnd += changeLength;
|
| 294 |
setCaretPosition(newCaretPosStart, newCaretPosEnd, oldCaretPos.rangeCopy); // set the caret position
|
| 295 |
}
|
| 296 |
// new revision id and otherschange == 0 , so no others' changes. case 1
|
| 297 |
else {
|
| 298 |
// the content will be the same we uploaded
|
| 299 |
oldContent = contentUploaded;
|
| 300 |
lastRevision = response.return_rev;
|
| 301 |
}
|
| 302 |
}
|
| 303 |
// Neither new revision nor others' change. case 2
|
| 304 |
else {
|
| 305 |
// debug var
|
| 306 |
phasejs = 2;
|
| 307 |
|
| 308 |
// if no diff uploaded we use the same values we uploaded last time
|
| 309 |
if (!isDiff) {
|
| 310 |
lastRevision = response.uploaded_rev;
|
| 311 |
oldContent = contentUploaded;
|
| 312 |
}
|
| 313 |
// we get here if the user is typing. Therefore we set the flag and call the updateDocument again three seconds later
|
| 314 |
// It may have a timeout
|
| 315 |
if (response.return_rev != "") {
|
| 316 |
userIsTyping = true;
|
| 317 |
setTimeout(function () { updateDocument(false); },3000);
|
| 318 |
}
|
| 319 |
}
|
| 320 |
|
| 321 |
// We alert the user that their last change is not valid. Meanwhile in the background the content from others is updated
|
| 322 |
// Note: if we exit the document the collision is not shown to the user. But something should be implemented to handle this anyway
|
| 323 |
if (response.collision == 1 && !exitDocument) {
|
| 324 |
alert("You have just typed some content where other users were writing. Your last changes have been undone");
|
| 325 |
}
|
| 326 |
|
| 327 |
showLastUpdate(response.collision, exitDocument) ;
|
| 328 |
|
| 329 |
showUserList(userList);
|
| 330 |
|
| 331 |
// debug vars
|
| 332 |
phase = response.phase;
|
| 333 |
usersdebug = response.currentusers;
|
| 334 |
|
| 335 |
// debug var
|
| 336 |
debughash = "phasejs: " + phasejs; // see which stage of handleresponse we are
|
| 337 |
/* Add this to the debughash if we want to see the caret variables
|
| 338 |
" server/client content length = " + serverContent.length // to check minuend and subtrahend
|
| 339 |
+ ":" + clientContent.length
|
| 340 |
+ " caretEnd = " + oldCaretPos.end
|
| 341 |
+ " diffStart = " + newContentStart
|
| 342 |
+ " lengthDiff = " + changeLength // the result of the subtraction
|
| 343 |
;
|
| 344 |
*/
|
| 345 |
}
|
| 346 |
|
| 347 |
|
| 348 |
|
| 349 |
function checkChanges()
|
| 350 |
{
|
| 351 |
var textArea = getTextArea();
|
| 352 |
var newContent = textArea.value;
|
| 353 |
|
| 354 |
// If the current content doesnt match with the last saved content we increment the new change var.
|
| 355 |
// newChange will be set to 0 next time we load the site
|
| 356 |
if (lastCheckedContent != newContent) {
|
| 357 |
lastCheckedContent = newContent;
|
| 358 |
newChange++;
|
| 359 |
}
|
| 360 |
return newChange;
|
| 361 |
}
|
| 362 |
|
| 363 |
|
| 364 |
// here we use an algorithm to extract the longest common subsequence of the two strings and then the difference block between them.
|
| 365 |
// Better algorithms are welcome!
|
| 366 |
// more info: http://cis.poly.edu/suel/papers/delta.pdf
|
| 367 |
function getDiffBlock(oldContent, newContent)
|
| 368 |
{
|
| 369 |
// Here we compare character by character to get the common prefix length
|
| 370 |
var prefixLength = 0;
|
| 371 |
var shortestLength = Math.min(oldContent.length, newContent.length);
|
| 372 |
while (prefixLength<shortestLength)
|
| 373 |
{
|
| 374 |
if (oldContent.charAt(prefixLength) == newContent.charAt(prefixLength))
|
| 375 |
prefixLength = prefixLength + 1;
|
| 376 |
else
|
| 377 |
shortestLength = prefixLength;
|
| 378 |
}
|
| 379 |
|
| 380 |
// Now we do the same to get the suffix length. The comparison starts in the end and then backwards
|
| 381 |
var suffixLength=0;
|
| 382 |
var oldRemainString = oldContent.slice(prefixLength, oldContent.length);
|
| 383 |
var newRemainString = newContent.slice(prefixLength, newContent.length);
|
| 384 |
shortestLength = Math.min(oldRemainString.length, newRemainString.length);
|
| 385 |
while (suffixLength<shortestLength)
|
| 386 |
{
|
| 387 |
if (oldRemainString.charAt(oldRemainString.length-suffixLength-1) == newRemainString.charAt(newRemainString.length-suffixLength-1))
|
| 388 |
suffixLength = suffixLength+1;
|
| 389 |
else
|
| 390 |
shortestLength = suffixLength;
|
| 391 |
}
|
| 392 |
|
| 393 |
// We also calculate if there has been any text replacement
|
| 394 |
var replacementLength = oldContent.length - prefixLength - suffixLength;
|
| 395 |
|
| 396 |
// And the diff content
|
| 397 |
var diffText = newContent.substr(prefixLength, newContent.length - prefixLength - suffixLength);
|
| 398 |
|
| 399 |
// We store the values in a hash
|
| 400 |
var diffResult = { prefixLength: prefixLength, suffixLength: suffixLength, replacementLength: replacementLength, diffText: diffText };
|
| 401 |
return diffResult;
|
| 402 |
}
|