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