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 * @param {!{memberId: !string, annotation: !Element}} info 101 * @return {undefined} 102 */ 103 function onAnnotationAdded(info) { 104 if (info.memberId === localMemberId) { 105 odfCanvas.getViewport().scrollIntoView(info.annotation.getBoundingClientRect()); 106 } 107 } 108 109 /** 110 * @return {!HTMLStyleElement} 111 */ 112 function newStyleSheet() { 113 var head = document.getElementsByTagName('head').item(0), 114 sheet = /**@type{!HTMLStyleElement}*/(document.createElement('style')); 115 116 sheet.type = 'text/css'; 117 sheet.media = 'screen, print, handheld, projection'; 118 head.appendChild(sheet); 119 120 return sheet; 121 } 122 123 /** 124 * @param {!string} nodeName 125 * @param {!string} memberId 126 * @param {!string} pseudoClass 127 * @return {!string} 128 */ 129 function createAvatarInfoNodeMatch(nodeName, memberId, pseudoClass) { 130 return nodeName + '[editinfo|memberid="' + memberId + '"]' + pseudoClass; 131 } 132 133 /** 134 * @param {!string} nodeName 135 * @param {!string} memberId 136 * @param {string} pseudoClass 137 * @return {?Node} 138 */ 139 function getAvatarInfoStyle(nodeName, memberId, pseudoClass) { 140 var node = avatarInfoStyles.firstChild, 141 // adding "{" to make sure indexOf(nodeMatch) === 0 does not match longer selectors with same start 142 nodeMatch = createAvatarInfoNodeMatch(nodeName, memberId, pseudoClass) + "{"; 143 144 while (node) { 145 if (node.nodeType === Node.TEXT_NODE && /**@type{!Text}*/(node).data.indexOf(nodeMatch) === 0) { 146 return node; 147 } 148 node = node.nextSibling; 149 } 150 return null; 151 } 152 153 /** 154 * @param {!string} memberId 155 * @param {!string} name 156 * @param {!string} color 157 * @return {undefined} 158 */ 159 function setAvatarInfoStyle(memberId, name, color) { 160 /** 161 * @param {!string} nodeName 162 * @param {!string} rule 163 * @param {!string} pseudoClass 164 */ 165 function setStyle(nodeName, rule, pseudoClass) { 166 var styleRule = createAvatarInfoNodeMatch(nodeName, memberId, pseudoClass) + rule, 167 styleNode = getAvatarInfoStyle(nodeName, memberId, pseudoClass); 168 169 // TODO: this does not work with Firefox 16.0.1, throws a HierarchyRequestError on first try. 170 // And Chromium a "SYNTAX_ERR: DOM Exception 12" now 171 // avatarEditedStyles.sheet.insertRule(paragraphStyleName+styleRuleRudimentCStr, 0); 172 // Workaround for now: 173 if (styleNode) { 174 styleNode.data = styleRule; 175 } else { 176 avatarInfoStyles.appendChild(document.createTextNode(styleRule)); 177 } 178 } 179 // WARNING: nodeMatch relies on that there is no space before the starting "{" 180 setStyle('div.editInfoMarker', '{ background-color: ' + color + '; }', ''); 181 setStyle('span.editInfoColor', '{ background-color: ' + color + '; }', ''); 182 setStyle('span.editInfoAuthor', '{ content: "' + name + '"; }', ':before'); 183 setStyle('dc|creator', '{ background-color: ' + color + '; }', ''); 184 setStyle('.webodf-selectionOverlay', '{ fill: ' + color + '; stroke: ' + color + ';}', ''); 185 if (memberId === localMemberId) { 186 // Show selection handles for local user 187 setStyle('.webodf-touchEnabled .webodf-selectionOverlay', '{ display: block; }', ' > .webodf-draggable'); 188 189 // Also set shadow cursor rules for local user 190 memberId = gui.ShadowCursor.ShadowCursorMemberId; 191 setStyle('.webodf-selectionOverlay', '{ fill: ' + color + '; stroke: ' + color + ';}', ''); 192 setStyle('.webodf-touchEnabled .webodf-selectionOverlay', '{ display: block; }', ' > .webodf-draggable'); 193 } 194 } 195 196 /** 197 * @param {!Element} element 198 * @param {!string} memberId 199 * @param {!number} timestamp 200 * @return {undefined} 201 */ 202 function highlightEdit(element, memberId, timestamp) { 203 var editInfo, 204 editInfoMarker, 205 id = '', 206 editInfoNode = element.getElementsByTagNameNS(editInfons, 'editinfo').item(0); 207 208 if (editInfoNode) { 209 id = /**@type{!Element}*/(editInfoNode).getAttributeNS(editInfons, 'id'); 210 editInfoMarker = editInfoMap[id]; 211 } else { 212 id = Math.random().toString(); 213 editInfo = new ops.EditInfo(element, session.getOdtDocument()); 214 editInfoMarker = new gui.EditInfoMarker(editInfo, showEditInfoMarkers); 215 216 editInfoNode = /**@type{!Element}*/(element.getElementsByTagNameNS(editInfons, 'editinfo').item(0)); 217 editInfoNode.setAttributeNS(editInfons, 'id', id); 218 editInfoMap[id] = editInfoMarker; 219 } 220 221 editInfoMarker.addEdit(memberId, new Date(timestamp)); 222 } 223 224 /** 225 * Updates the visibility on all existing editInfo entries 226 * @param {!boolean} visible 227 * @return {undefined} 228 */ 229 function setEditInfoMarkerVisibility(visible) { 230 var editInfoMarker, 231 /**@type{string}*/ 232 keyname; 233 234 for (keyname in editInfoMap) { 235 if (editInfoMap.hasOwnProperty(keyname)) { 236 editInfoMarker = editInfoMap[keyname]; 237 if (visible) { 238 editInfoMarker.show(); 239 } else { 240 editInfoMarker.hide(); 241 } 242 } 243 } 244 } 245 246 /** 247 * Updates the visibility on all existing avatars 248 * @param {!boolean} visible 249 * @return {undefined} 250 */ 251 function setCaretAvatarVisibility(visible) { 252 caretManager.getCarets().forEach(function (caret) { 253 if (visible) { 254 caret.showHandle(); 255 } else { 256 caret.hideHandle(); 257 } 258 }); 259 } 260 261 /** 262 * Show edit information markers displayed near edited paragraphs 263 * @return {undefined} 264 */ 265 this.showEditInfoMarkers = function () { 266 if (showEditInfoMarkers) { 267 return; 268 } 269 270 showEditInfoMarkers = true; 271 setEditInfoMarkerVisibility(showEditInfoMarkers); 272 }; 273 274 /** 275 * Hide edit information markers displayed near edited paragraphs 276 * @return {undefined} 277 */ 278 this.hideEditInfoMarkers = function () { 279 if (!showEditInfoMarkers) { 280 return; 281 } 282 283 showEditInfoMarkers = false; 284 setEditInfoMarkerVisibility(showEditInfoMarkers); 285 }; 286 287 /** 288 * Show member avatars above the cursor 289 * @return {undefined} 290 */ 291 this.showCaretAvatars = function () { 292 if (showCaretAvatars) { 293 return; 294 } 295 296 showCaretAvatars = true; 297 setCaretAvatarVisibility(showCaretAvatars); 298 }; 299 300 /** 301 * Hide member avatars above the cursor 302 * @return {undefined} 303 */ 304 this.hideCaretAvatars = function () { 305 if (!showCaretAvatars) { 306 return; 307 } 308 309 showCaretAvatars = false; 310 setCaretAvatarVisibility(showCaretAvatars); 311 }; 312 313 /** 314 * @return {!ops.Session} 315 */ 316 this.getSession = function () { 317 return session; 318 }; 319 /** 320 * @param {!string} memberid 321 * @return {?gui.Caret} 322 */ 323 this.getCaret = function (memberid) { 324 return caretManager.getCaret(memberid); 325 }; 326 327 /** 328 * @param {!ops.Member} member 329 * @return {undefined} 330 */ 331 function renderMemberData(member) { 332 var memberId = member.getMemberId(), 333 properties = member.getProperties(); 334 335 setAvatarInfoStyle(memberId, properties.fullName, properties.color); 336 } 337 338 /** 339 * @param {!ops.OdtCursor} cursor 340 * @return {undefined} 341 */ 342 function onCursorAdded(cursor) { 343 var memberId = cursor.getMemberId(), 344 properties = session.getOdtDocument().getMember(memberId).getProperties(), 345 caret; 346 347 caretManager.registerCursor(cursor, showCaretAvatars, blinkOnRangeSelect); 348 selectionViewManager.registerCursor(cursor, true); 349 350 caret = caretManager.getCaret(memberId); 351 if (caret) { 352 caret.setAvatarImageUrl(properties.imageUrl); 353 caret.setColor(properties.color); 354 } 355 runtime.log("+++ View here +++ eagerly created an Caret for '" + memberId + "'! +++"); 356 } 357 358 /** 359 * @param {!ops.OdtCursor} cursor 360 * @return {undefined} 361 */ 362 function onCursorMoved(cursor) { 363 var memberId = cursor.getMemberId(), 364 localSelectionView = selectionViewManager.getSelectionView(localMemberId), 365 shadowSelectionView = selectionViewManager.getSelectionView(gui.ShadowCursor.ShadowCursorMemberId), 366 localCaret = caretManager.getCaret(localMemberId); 367 368 if (memberId === localMemberId) { 369 // If our actual cursor moved, then hide the shadow cursor's selection 370 shadowSelectionView.hide(); 371 if (localSelectionView) { 372 localSelectionView.show(); 373 } 374 if (localCaret) { 375 localCaret.show(); 376 } 377 } else if (memberId === gui.ShadowCursor.ShadowCursorMemberId) { 378 // If the shadow cursor moved, then hide the current cursor's selection 379 shadowSelectionView.show(); 380 if (localSelectionView) { 381 localSelectionView.hide(); 382 } 383 if (localCaret) { 384 localCaret.hide(); 385 } 386 } 387 } 388 389 /** 390 * @param {!string} memberid 391 * @return {undefined} 392 */ 393 function onCursorRemoved(memberid) { 394 selectionViewManager.removeSelectionView(memberid); 395 } 396 397 /** 398 * @param {!{paragraphElement:!Element,memberId:string,timeStamp:number}} info 399 * @return {undefined} 400 */ 401 function onParagraphChanged(info) { 402 highlightEdit(info.paragraphElement, info.memberId, info.timeStamp); 403 highlightRefreshTask.trigger(); 404 } 405 406 /** 407 * @return {undefined} 408 */ 409 function refreshHighlights() { 410 var annotationViewManager = odfCanvas.getAnnotationViewManager(); 411 if (annotationViewManager) { 412 annotationViewManager.rehighlightAnnotations(); 413 odtDocument.fixCursorPositions(); 414 } 415 } 416 417 function processConstraints() { 418 var localMemberName, 419 cssString, 420 localMember; 421 422 // TODO: Move such handling into AnnotationViewManager 423 if (annotationConstraintStyles.hasChildNodes()) { 424 core.DomUtils.removeAllChildNodes(annotationConstraintStyles); 425 } 426 427 if (sessionConstraints.getState(gui.CommonConstraints.EDIT.ANNOTATIONS.ONLY_DELETE_OWN) === true) { 428 localMember = session.getOdtDocument().getMember(localMemberId); 429 if (localMember) { 430 localMemberName = localMember.getProperties().fullName; 431 cssString = ".annotationWrapper:not([creator = '" + localMemberName + "']) .annotationRemoveButton { display: none; }"; 432 annotationConstraintStyles.appendChild(document.createTextNode(cssString)); 433 } 434 } 435 } 436 437 /** 438 * @param {!function(!Error=)} callback 439 * @return {undefined} 440 */ 441 function destroy(callback) { 442 var /**@type{!Array.<!gui.EditInfoMarker>}*/ 443 editInfoArray = Object.keys(editInfoMap).map(function (keyname) { 444 return editInfoMap[keyname]; 445 }); 446 447 odtDocument.unsubscribe(ops.Document.signalMemberAdded, renderMemberData); 448 odtDocument.unsubscribe(ops.Document.signalMemberUpdated, renderMemberData); 449 odtDocument.unsubscribe(ops.Document.signalCursorAdded, onCursorAdded); 450 odtDocument.unsubscribe(ops.Document.signalCursorRemoved, onCursorRemoved); 451 odtDocument.unsubscribe(ops.OdtDocument.signalParagraphChanged, onParagraphChanged); 452 odtDocument.unsubscribe(ops.Document.signalCursorMoved, onCursorMoved); 453 454 odtDocument.unsubscribe(ops.OdtDocument.signalParagraphChanged, selectionViewManager.rerenderSelectionViews); 455 odtDocument.unsubscribe(ops.OdtDocument.signalTableAdded, selectionViewManager.rerenderSelectionViews); 456 odtDocument.unsubscribe(ops.OdtDocument.signalParagraphStyleModified, selectionViewManager.rerenderSelectionViews); 457 458 sessionConstraints.unsubscribe(gui.CommonConstraints.EDIT.ANNOTATIONS.ONLY_DELETE_OWN, processConstraints); 459 odtDocument.unsubscribe(ops.Document.signalMemberAdded, processConstraints); 460 odtDocument.unsubscribe(ops.Document.signalMemberUpdated, processConstraints); 461 462 avatarInfoStyles.parentNode.removeChild(avatarInfoStyles); 463 annotationConstraintStyles.parentNode.removeChild(annotationConstraintStyles); 464 465 (function destroyEditInfo(i, err) { 466 if (err) { 467 callback(err); 468 } else { 469 if (i < editInfoArray.length) { 470 editInfoArray[i].destroy(function (err) { 471 destroyEditInfo(i + 1, err); 472 }); 473 } else { 474 callback(); 475 } 476 } 477 }(0, undefined)); 478 } 479 480 /** 481 * @param {!function(!Error=)} callback, passing an error object in case of error 482 * @return {undefined} 483 */ 484 this.destroy = function (callback) { 485 var cleanup = [highlightRefreshTask.destroy, destroy]; 486 odtDocument.unsubscribe(ops.OdtDocument.signalAnnotationAdded, onAnnotationAdded); 487 core.Async.destroyAll(cleanup, callback); 488 }; 489 490 function init() { 491 odtDocument = session.getOdtDocument(); 492 odfCanvas = odtDocument.getOdfCanvas(); 493 494 odtDocument.subscribe(ops.OdtDocument.signalAnnotationAdded, onAnnotationAdded); 495 odtDocument.subscribe(ops.Document.signalMemberAdded, renderMemberData); 496 odtDocument.subscribe(ops.Document.signalMemberUpdated, renderMemberData); 497 odtDocument.subscribe(ops.Document.signalCursorAdded, onCursorAdded); 498 odtDocument.subscribe(ops.Document.signalCursorRemoved, onCursorRemoved); 499 odtDocument.subscribe(ops.OdtDocument.signalParagraphChanged, onParagraphChanged); 500 odtDocument.subscribe(ops.Document.signalCursorMoved, onCursorMoved); 501 502 odtDocument.subscribe(ops.OdtDocument.signalParagraphChanged, selectionViewManager.rerenderSelectionViews); 503 odtDocument.subscribe(ops.OdtDocument.signalTableAdded, selectionViewManager.rerenderSelectionViews); 504 odtDocument.subscribe(ops.OdtDocument.signalParagraphStyleModified, selectionViewManager.rerenderSelectionViews); 505 506 sessionConstraints.subscribe(gui.CommonConstraints.EDIT.ANNOTATIONS.ONLY_DELETE_OWN, processConstraints); 507 odtDocument.subscribe(ops.Document.signalMemberAdded, processConstraints); 508 odtDocument.subscribe(ops.Document.signalMemberUpdated, processConstraints); 509 510 // Add a css sheet for user info-edited styling 511 avatarInfoStyles = newStyleSheet(); 512 avatarInfoStyles.appendChild(document.createTextNode('@namespace editinfo url(urn:webodf:names:editinfo);')); 513 avatarInfoStyles.appendChild(document.createTextNode('@namespace dc url(http://purl.org/dc/elements/1.1/);')); 514 // Add a css sheet for annotation constraint styling 515 annotationConstraintStyles = newStyleSheet(); 516 processConstraints(); 517 518 highlightRefreshTask = core.Task.createRedrawTask(refreshHighlights); 519 } 520 init(); 521 }; 522 }()); 523