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