1 /** 2 * Copyright (C) 2012-2013 KO GmbH <copyright@kogmbh.com> 3 * 4 * @licstart 5 * This file is part of WebODF. 6 * 7 * WebODF is free software: you can redistribute it and/or modify it 8 * under the terms of the GNU Affero General Public License (GNU AGPL) 9 * as published by the Free Software Foundation, either version 3 of 10 * the License, or (at your option) any later version. 11 * 12 * WebODF is distributed in the hope that it will be useful, but 13 * WITHOUT ANY WARRANTY; without even the implied warranty of 14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 * GNU Affero General Public License for more details. 16 * 17 * You should have received a copy of the GNU Affero General Public License 18 * along with WebODF. If not, see <http://www.gnu.org/licenses/>. 19 * @licend 20 * 21 * @source: http://www.webodf.org/ 22 * @source: https://github.com/kogmbh/WebODF/ 23 */ 24 25 /*global Node, document, runtime, gui, ops, core */ 26 27 /** 28 * @constructor 29 * @struct 30 */ 31 gui.SessionViewOptions = function () { 32 "use strict"; 33 34 /** 35 * Set the initial edit information marker visibility 36 * @type {boolean} 37 */ 38 this.editInfoMarkersInitiallyVisible = true; 39 40 /** 41 * Sets the initial visibility of the avatar 42 * @type {boolean} 43 */ 44 this.caretAvatarsInitiallyVisible = true; 45 46 /** 47 * Specify that the caret should blink if a non-collapsed range is selected 48 * @type {boolean} 49 */ 50 this.caretBlinksOnRangeSelect = true; 51 }; 52 53 (function () { 54 "use strict"; 55 56 /** 57 * Return a user-specified option, or the default value if no user option 58 * is provided 59 * @param {boolean} userValue 60 * @param {!boolean} defaultValue 61 * @return {!boolean} 62 */ 63 function configOption(userValue, defaultValue) { 64 return userValue !== undefined ? Boolean(userValue) : defaultValue; 65 } 66 67 /** 68 * TODO: We really don't want to let SessionView be aware of localMemberId, 69 * so eventually we'll need to refactor this. It is only here so that the id can 70 * be matched with the memberids for which CSS is generated, to generate the same CSS 71 * for shadow cursors. 72 * @constructor 73 * @implements {core.Destroyable} 74 * @param {!gui.SessionViewOptions} viewOptions 75 * @param {string} localMemberId 76 * @param {!ops.Session} session 77 * @param {!gui.SessionConstraints} sessionConstraints 78 * @param {!gui.CaretManager} caretManager 79 * @param {!gui.SelectionViewManager} selectionViewManager 80 */ 81 gui.SessionView = function SessionView(viewOptions, localMemberId, session, sessionConstraints, caretManager, selectionViewManager) { 82 var /**@type{!HTMLStyleElement}*/ 83 avatarInfoStyles, 84 /**@type{!HTMLStyleElement}*/ 85 annotationConstraintStyles, 86 editInfons = 'urn:webodf:names:editinfo', 87 /**@type{!Object.<string,!gui.EditInfoMarker>}*/ 88 editInfoMap = {}, 89 /**@type{!ops.OdtDocument}*/ 90 odtDocument, 91 /**@type{!odf.OdfCanvas}*/ 92 odfCanvas, 93 /**@type{!core.ScheduledTask}*/ 94 highlightRefreshTask, 95 showEditInfoMarkers = configOption(viewOptions.editInfoMarkersInitiallyVisible, true), 96 showCaretAvatars = configOption(viewOptions.caretAvatarsInitiallyVisible, true), 97 blinkOnRangeSelect = configOption(viewOptions.caretBlinksOnRangeSelect, true); 98 99 /** 100 * @return {!HTMLStyleElement} 101 */ 102 function newStyleSheet() { 103 var head = document.getElementsByTagName('head').item(0), 104 sheet = /**@type{!HTMLStyleElement}*/(document.createElement('style')); 105 106 sheet.type = 'text/css'; 107 sheet.media = 'screen, print, handheld, projection'; 108 head.appendChild(sheet); 109 110 return sheet; 111 } 112 113 /** 114 * @param {!string} nodeName 115 * @param {!string} memberId 116 * @param {!string} pseudoClass 117 * @return {!string} 118 */ 119 function createAvatarInfoNodeMatch(nodeName, memberId, pseudoClass) { 120 return nodeName + '[editinfo|memberid="' + memberId + '"]' + pseudoClass; 121 } 122 123 /** 124 * @param {!string} nodeName 125 * @param {!string} memberId 126 * @param {string} pseudoClass 127 * @return {?Node} 128 */ 129 function getAvatarInfoStyle(nodeName, memberId, pseudoClass) { 130 var node = avatarInfoStyles.firstChild, 131 // adding "{" to make sure indexOf(nodeMatch) === 0 does not match longer selectors with same start 132 nodeMatch = createAvatarInfoNodeMatch(nodeName, memberId, pseudoClass) + "{"; 133 134 while (node) { 135 if (node.nodeType === Node.TEXT_NODE && /**@type{!Text}*/(node).data.indexOf(nodeMatch) === 0) { 136 return node; 137 } 138 node = node.nextSibling; 139 } 140 return null; 141 } 142 143 /** 144 * @param {!string} memberId 145 * @param {!string} name 146 * @param {!string} color 147 * @return {undefined} 148 */ 149 function setAvatarInfoStyle(memberId, name, color) { 150 /** 151 * @param {!string} nodeName 152 * @param {!string} rule 153 * @param {!string} pseudoClass 154 */ 155 function setStyle(nodeName, rule, pseudoClass) { 156 var styleRule = createAvatarInfoNodeMatch(nodeName, memberId, pseudoClass) + rule, 157 styleNode = getAvatarInfoStyle(nodeName, memberId, pseudoClass); 158 159 // TODO: this does not work with Firefox 16.0.1, throws a HierarchyRequestError on first try. 160 // And Chromium a "SYNTAX_ERR: DOM Exception 12" now 161 // avatarEditedStyles.sheet.insertRule(paragraphStyleName+styleRuleRudimentCStr, 0); 162 // Workaround for now: 163 if (styleNode) { 164 styleNode.data = styleRule; 165 } else { 166 avatarInfoStyles.appendChild(document.createTextNode(styleRule)); 167 } 168 } 169 // WARNING: nodeMatch relies on that there is no space before the starting "{" 170 setStyle('div.editInfoMarker', '{ background-color: ' + color + '; }', ''); 171 setStyle('span.editInfoColor', '{ background-color: ' + color + '; }', ''); 172 setStyle('span.editInfoAuthor', '{ content: "' + name + '"; }', ':before'); 173 setStyle('dc|creator', '{ background-color: ' + color + '; }', ''); 174 setStyle('.webodf-selectionOverlay', '{ fill: ' + color + '; stroke: ' + color + ';}', ''); 175 // Hide the handles of non-local users 176 if (memberId === gui.ShadowCursor.ShadowCursorMemberId || memberId === localMemberId) { 177 setStyle('.webodf-touchEnabled .webodf-selectionOverlay', '{ display: block; }', ' > .webodf-draggable'); 178 } 179 } 180 181 /** 182 * @param {!Element} element 183 * @param {!string} memberId 184 * @param {!number} timestamp 185 * @return {undefined} 186 */ 187 function highlightEdit(element, memberId, timestamp) { 188 var editInfo, 189 editInfoMarker, 190 id = '', 191 editInfoNode = element.getElementsByTagNameNS(editInfons, 'editinfo').item(0); 192 193 if (editInfoNode) { 194 id = /**@type{!Element}*/(editInfoNode).getAttributeNS(editInfons, 'id'); 195 editInfoMarker = editInfoMap[id]; 196 } else { 197 id = Math.random().toString(); 198 editInfo = new ops.EditInfo(element, session.getOdtDocument()); 199 editInfoMarker = new gui.EditInfoMarker(editInfo, showEditInfoMarkers); 200 201 editInfoNode = /**@type{!Element}*/(element.getElementsByTagNameNS(editInfons, 'editinfo').item(0)); 202 editInfoNode.setAttributeNS(editInfons, 'id', id); 203 editInfoMap[id] = editInfoMarker; 204 } 205 206 editInfoMarker.addEdit(memberId, new Date(timestamp)); 207 } 208 209 /** 210 * Updates the visibility on all existing editInfo entries 211 * @param {!boolean} visible 212 * @return {undefined} 213 */ 214 function setEditInfoMarkerVisibility(visible) { 215 var editInfoMarker, 216 /**@type{string}*/ 217 keyname; 218 219 for (keyname in editInfoMap) { 220 if (editInfoMap.hasOwnProperty(keyname)) { 221 editInfoMarker = editInfoMap[keyname]; 222 if (visible) { 223 editInfoMarker.show(); 224 } else { 225 editInfoMarker.hide(); 226 } 227 } 228 } 229 } 230 231 /** 232 * Updates the visibility on all existing avatars 233 * @param {!boolean} visible 234 * @return {undefined} 235 */ 236 function setCaretAvatarVisibility(visible) { 237 caretManager.getCarets().forEach(function (caret) { 238 if (visible) { 239 caret.showHandle(); 240 } else { 241 caret.hideHandle(); 242 } 243 }); 244 } 245 246 /** 247 * Show edit information markers displayed near edited paragraphs 248 * @return {undefined} 249 */ 250 this.showEditInfoMarkers = function () { 251 if (showEditInfoMarkers) { 252 return; 253 } 254 255 showEditInfoMarkers = true; 256 setEditInfoMarkerVisibility(showEditInfoMarkers); 257 }; 258 259 /** 260 * Hide edit information markers displayed near edited paragraphs 261 * @return {undefined} 262 */ 263 this.hideEditInfoMarkers = function () { 264 if (!showEditInfoMarkers) { 265 return; 266 } 267 268 showEditInfoMarkers = false; 269 setEditInfoMarkerVisibility(showEditInfoMarkers); 270 }; 271 272 /** 273 * Show member avatars above the cursor 274 * @return {undefined} 275 */ 276 this.showCaretAvatars = function () { 277 if (showCaretAvatars) { 278 return; 279 } 280 281 showCaretAvatars = true; 282 setCaretAvatarVisibility(showCaretAvatars); 283 }; 284 285 /** 286 * Hide member avatars above the cursor 287 * @return {undefined} 288 */ 289 this.hideCaretAvatars = function () { 290 if (!showCaretAvatars) { 291 return; 292 } 293 294 showCaretAvatars = false; 295 setCaretAvatarVisibility(showCaretAvatars); 296 }; 297 298 /** 299 * @return {!ops.Session} 300 */ 301 this.getSession = function () { 302 return session; 303 }; 304 /** 305 * @param {!string} memberid 306 * @return {?gui.Caret} 307 */ 308 this.getCaret = function (memberid) { 309 return caretManager.getCaret(memberid); 310 }; 311 312 /** 313 * @param {!ops.Member} member 314 * @return {undefined} 315 */ 316 function renderMemberData(member) { 317 var memberId = member.getMemberId(), 318 properties = member.getProperties(); 319 320 setAvatarInfoStyle(memberId, properties.fullName, properties.color); 321 if (localMemberId === memberId) { 322 // Shadow cursor has an empty member ID 323 setAvatarInfoStyle("", "", properties.color); 324 } 325 } 326 327 /** 328 * @param {!ops.OdtCursor} cursor 329 * @return {undefined} 330 */ 331 function onCursorAdded(cursor) { 332 var memberId = cursor.getMemberId(), 333 properties = session.getOdtDocument().getMember(memberId).getProperties(), 334 caret; 335 336 caretManager.registerCursor(cursor, showCaretAvatars, blinkOnRangeSelect); 337 selectionViewManager.registerCursor(cursor, true); 338 339 caret = caretManager.getCaret(memberId); 340 if (caret) { 341 caret.setAvatarImageUrl(properties.imageUrl); 342 caret.setColor(properties.color); 343 } 344 runtime.log("+++ View here +++ eagerly created an Caret for '" + memberId + "'! +++"); 345 } 346 347 /** 348 * @param {!ops.OdtCursor} cursor 349 * @return {undefined} 350 */ 351 function onCursorMoved(cursor) { 352 var memberId = cursor.getMemberId(), 353 localSelectionView = selectionViewManager.getSelectionView(localMemberId), 354 shadowSelectionView = selectionViewManager.getSelectionView(gui.ShadowCursor.ShadowCursorMemberId), 355 localCaret = caretManager.getCaret(localMemberId); 356 357 if (memberId === localMemberId) { 358 // If our actual cursor moved, then hide the shadow cursor's selection 359 shadowSelectionView.hide(); 360 if (localSelectionView) { 361 localSelectionView.show(); 362 } 363 if (localCaret) { 364 localCaret.show(); 365 } 366 } else if (memberId === gui.ShadowCursor.ShadowCursorMemberId) { 367 // If the shadow cursor moved, then hide the current cursor's selection 368 shadowSelectionView.show(); 369 if (localSelectionView) { 370 localSelectionView.hide(); 371 } 372 if (localCaret) { 373 localCaret.hide(); 374 } 375 } 376 } 377 378 /** 379 * @param {!string} memberid 380 * @return {undefined} 381 */ 382 function onCursorRemoved(memberid) { 383 selectionViewManager.removeSelectionView(memberid); 384 } 385 386 /** 387 * @param {!{paragraphElement:!Element,memberId:string,timeStamp:number}} info 388 * @return {undefined} 389 */ 390 function onParagraphChanged(info) { 391 highlightEdit(info.paragraphElement, info.memberId, info.timeStamp); 392 highlightRefreshTask.trigger(); 393 } 394 395 /** 396 * @return {undefined} 397 */ 398 function refreshHighlights() { 399 var annotationViewManager = odfCanvas.getAnnotationViewManager(); 400 if (annotationViewManager) { 401 annotationViewManager.rehighlightAnnotations(); 402 odtDocument.fixCursorPositions(); 403 } 404 } 405 406 function processConstraints() { 407 var localMemberName, 408 cssString, 409 localMember; 410 411 // TODO: Move such handling into AnnotationViewManager 412 if (annotationConstraintStyles.innerHTML !== "") { 413 annotationConstraintStyles.innerHTML = ""; 414 } 415 416 if (sessionConstraints.getState(gui.CommonConstraints.EDIT.ANNOTATIONS.ONLY_DELETE_OWN) === true) { 417 localMember = session.getOdtDocument().getMember(localMemberId); 418 if (localMember) { 419 localMemberName = localMember.getProperties().fullName; 420 cssString = ".annotationWrapper:not([creator = '" + localMemberName + "']) .annotationRemoveButton { display: none; }"; 421 annotationConstraintStyles.appendChild(document.createTextNode(cssString)); 422 } 423 } 424 } 425 426 /** 427 * @param {!function(!Error=)} callback 428 * @return {undefined} 429 */ 430 function destroy(callback) { 431 var /**@type{!Array.<!gui.EditInfoMarker>}*/ 432 editInfoArray = Object.keys(editInfoMap).map(function (keyname) { 433 return editInfoMap[keyname]; 434 }); 435 436 odtDocument.unsubscribe(ops.Document.signalMemberAdded, renderMemberData); 437 odtDocument.unsubscribe(ops.Document.signalMemberUpdated, renderMemberData); 438 odtDocument.unsubscribe(ops.Document.signalCursorAdded, onCursorAdded); 439 odtDocument.unsubscribe(ops.Document.signalCursorRemoved, onCursorRemoved); 440 odtDocument.unsubscribe(ops.OdtDocument.signalParagraphChanged, onParagraphChanged); 441 odtDocument.unsubscribe(ops.Document.signalCursorMoved, onCursorMoved); 442 443 odtDocument.unsubscribe(ops.OdtDocument.signalParagraphChanged, selectionViewManager.rerenderSelectionViews); 444 odtDocument.unsubscribe(ops.OdtDocument.signalTableAdded, selectionViewManager.rerenderSelectionViews); 445 odtDocument.unsubscribe(ops.OdtDocument.signalParagraphStyleModified, selectionViewManager.rerenderSelectionViews); 446 447 sessionConstraints.unsubscribe(gui.CommonConstraints.EDIT.ANNOTATIONS.ONLY_DELETE_OWN, processConstraints); 448 odtDocument.unsubscribe(ops.Document.signalMemberAdded, processConstraints); 449 odtDocument.unsubscribe(ops.Document.signalMemberUpdated, processConstraints); 450 451 avatarInfoStyles.parentNode.removeChild(avatarInfoStyles); 452 annotationConstraintStyles.parentNode.removeChild(annotationConstraintStyles); 453 454 (function destroyEditInfo(i, err) { 455 if (err) { 456 callback(err); 457 } else { 458 if (i < editInfoArray.length) { 459 editInfoArray[i].destroy(function (err) { 460 destroyEditInfo(i + 1, err); 461 }); 462 } else { 463 callback(); 464 } 465 } 466 }(0, undefined)); 467 } 468 469 /** 470 * @param {!function(!Error=)} callback, passing an error object in case of error 471 * @return {undefined} 472 */ 473 this.destroy = function (callback) { 474 var cleanup = [highlightRefreshTask.destroy, destroy]; 475 core.Async.destroyAll(cleanup, callback); 476 }; 477 478 function init() { 479 odtDocument = session.getOdtDocument(); 480 odfCanvas = odtDocument.getOdfCanvas(); 481 482 odtDocument.subscribe(ops.Document.signalMemberAdded, renderMemberData); 483 odtDocument.subscribe(ops.Document.signalMemberUpdated, renderMemberData); 484 odtDocument.subscribe(ops.Document.signalCursorAdded, onCursorAdded); 485 odtDocument.subscribe(ops.Document.signalCursorRemoved, onCursorRemoved); 486 odtDocument.subscribe(ops.OdtDocument.signalParagraphChanged, onParagraphChanged); 487 odtDocument.subscribe(ops.Document.signalCursorMoved, onCursorMoved); 488 489 odtDocument.subscribe(ops.OdtDocument.signalParagraphChanged, selectionViewManager.rerenderSelectionViews); 490 odtDocument.subscribe(ops.OdtDocument.signalTableAdded, selectionViewManager.rerenderSelectionViews); 491 odtDocument.subscribe(ops.OdtDocument.signalParagraphStyleModified, selectionViewManager.rerenderSelectionViews); 492 493 sessionConstraints.subscribe(gui.CommonConstraints.EDIT.ANNOTATIONS.ONLY_DELETE_OWN, processConstraints); 494 odtDocument.subscribe(ops.Document.signalMemberAdded, processConstraints); 495 odtDocument.subscribe(ops.Document.signalMemberUpdated, processConstraints); 496 497 // Add a css sheet for user info-edited styling 498 avatarInfoStyles = newStyleSheet(); 499 avatarInfoStyles.appendChild(document.createTextNode('@namespace editinfo url(urn:webodf:names:editinfo);')); 500 avatarInfoStyles.appendChild(document.createTextNode('@namespace dc url(http://purl.org/dc/elements/1.1/);')); 501 // Add a css sheet for annotation constraint styling 502 annotationConstraintStyles = newStyleSheet(); 503 processConstraints(); 504 505 highlightRefreshTask = core.Task.createRedrawTask(refreshHighlights); 506 } 507 init(); 508 }; 509 }()); 510