getattr -> restrictedTraverse : si on utilise geattr au lieu de restrictedTraverse...
[Portfolio.git] / skins / photo_film_viewer.js
1 /*
2 * © 2008 Benoît Pin – Centre de recherche en informatique – École des mines de Paris
3 * http://plinn.org
4 * Licence Creative Commons http://creativecommons.org/licenses/by-nc/2.0/
5 *
6 *
7 */
8
9 var FilmSlider;
10
11 (function(){
12
13 var keyLeft = 37, keyRight = 39;
14 var isTextMime = /^text\/.+/i;
15 var isAddToSelection = /.*\/add_to_selection$/;
16 var imgRequestedSize = /size=(\d+)/;
17 var DEFAULT_IMAGE_SIZES = [500, 600, 800];
18
19 FilmSlider = function(filmBar, slider, ctxInfos, image, toolbar, breadcrumbs) {
20 var thisSlider = this;
21 this.filmBar = filmBar;
22 var film = filmBar.firstChild;
23 if (film.nodeType == 3)
24 film = film.nextSibling;
25 this.film = film;
26 this.slider = slider;
27 this.rail = slider.parentNode;
28 this.sliderSpeedRatio = undefined;
29 this.sliderRatio = undefined;
30 this.selectedSlide = undefined;
31 this.selectedSlideInSelection = undefined;
32 this.cartSlide = document.getElementById('cart_slide');
33 this.image = image;
34 this.stretchable = image.parentNode;
35 this.viewMode = 'medium';
36
37 this.buttons = new Array();
38 this.toolbar = toolbar;
39 var bcElements = breadcrumbs.getElementsByTagName('a');
40 this.lastBCElement = bcElements[bcElements.length-1];
41 var imgSrcParts = image.src.split('/');
42 this.lastBCElement.innerHTML = imgSrcParts[imgSrcParts.length-2];
43
44 var buttons = toolbar.getElementsByTagName('img');
45 var b, name;
46 for (var i=0 ; i<buttons.length ; i++) {
47 b = buttons[i];
48 name = b.getAttribute('name');
49 if (name)
50 this.buttons[name] = b;
51 }
52
53 this.pendingImage = new Image();
54 this.pendingImage.onload = function() {
55 thisSlider.refreshImage();
56 };
57 this.initialized = false;
58
59 with(this.slider.style) {
60 left='0';
61 top='0';
62 }
63 with(this.film.style) {
64 left='0';
65 top='0';
66 }
67
68 this.filmLength = ctxInfos.filmLength;
69 this.center = ctxInfos.center;
70 this.slideSize = ctxInfos.slideSize;
71 this.ctxUrlTranslation = ctxInfos.ctxUrlTranslation;
72
73 this.ddHandlers = {'down' : function(evt){thisSlider.mouseDownHandler(evt);},
74 'move' : function(evt){thisSlider.mouseMoveHandler(evt);},
75 'up' : function(evt){thisSlider.mouseUpHandler(evt);},
76 'out' : function(evt){thisSlider.mouseOutHandler(evt);}
77 };
78
79 this.resizeSlider();
80 this.addEventListeners()
81 };
82
83
84 FilmSlider.prototype.resizeSlider = function(evt) {
85 var filmBarWidth = getObjectWidth(this.filmBar);
86 if (!filmBarWidth){
87 var thisSlider = this;
88 addListener(window, 'load', function(evt){thisSlider.resizeSlider(evt);});
89 return;
90 }
91
92 var filmWidth = this.slideSize * this.filmLength;
93 var sliderRatio = this.sliderRatio = filmBarWidth / filmWidth;
94 var sliderWidth = filmBarWidth * sliderRatio;
95 this.rail.style.width = filmBarWidth + 'px';
96 this.rail.style.display = 'block';
97 this.rail.style.visibility = 'visible';
98 if (sliderRatio < 1) {
99 this.slider.style.width = Math.round(sliderWidth) + 'px';
100 this.slider.style.visibility = 'visible';
101 }
102 else {
103 this.slider.style.visibility = 'hidden';
104 }
105
106 this.winSize = {'width' : getWindowWidth(),
107 'height' : getWindowHeight()};
108 this.maxRightPosition = filmBarWidth - sliderWidth
109 this.sliderSpeedRatio = - (filmBarWidth - sliderWidth) / (filmWidth - filmBarWidth);
110 if (!this.initialized) {
111 this.centerSlide(this.center);
112 this.selectedSlide = this.filmBar.getElementsByTagName('img')[this.center].parentNode;
113 this.initialized = true;
114 }
115 };
116
117 FilmSlider.prototype.fitToScreen = function(evt) {
118 this._fitToScreen();
119 var thisSlider = this;
120 addListener(window, 'resize', function(evt){thisSlider._fitToScreen();});
121 };
122
123 FilmSlider.prototype._fitToScreen = function(evt) {
124 var wh = getWindowHeight();
125 var rb = getObjectTop(this.rail) + getObjectHeight(this.rail); // rail bottom
126 var delta = wh - rb
127 var sh = getObjectHeight(this.stretchable);
128 var newSize = sh + delta;
129 this.stretchable.style.height = newSize + 'px';
130
131 var ratio = this.image.height / this.image.width;
132 var bestFitSize = this.getBestFitSize(ratio);
133 var currentSize = parseInt(imgRequestedSize.exec(this.image.src)[1]);
134 if (currentSize != bestFitSize) {
135 var src = this.image.src.replace(imgRequestedSize, 'size=' + bestFitSize);
136 this.pendingImage.src = src;
137 }
138 };
139
140 FilmSlider.prototype.getBestFitSize = function(ratio) {
141 var fw = getObjectWidth(this.stretchable) - 1;
142 var fh = getObjectHeight(this.stretchable) - 1;
143
144 var i, irw, irh;
145 if (ratio < 1) {
146 for (i=DEFAULT_IMAGE_SIZES.length -1 ; i>0 ; i--) {
147 irw = DEFAULT_IMAGE_SIZES[i];
148 irh = irw * ratio;
149 if (irw <= fw && irh <= fh)
150 break;
151 }
152 }
153 else {
154 for (i=DEFAULT_IMAGE_SIZES.length -1 ; i>0 ; i--) {
155 irh = DEFAULT_IMAGE_SIZES[i];
156 irw = irh / ratio;
157 if (irw <= fw && irh <= fh)
158 break;
159 }
160 }
161 return DEFAULT_IMAGE_SIZES[i];
162 };
163
164 FilmSlider.prototype.centerSlide = function(slideIndex) {
165 if (this.sliderRatio > 1)
166 return;
167 var filmBarWidth = getObjectWidth(this.filmBar);
168 var x = slideIndex * this.slideSize
169 x = x - (filmBarWidth - this.slideSize) / 2.0;
170 x = x * this.sliderSpeedRatio;
171 var p = new Point( -x, 0 )
172 this.setSliderPosition(p);
173 };
174
175 FilmSlider.prototype.setSliderPosition = function(point) {
176 if(point.x < 0)
177 point.x = 0;
178 if (point.x > this.maxRightPosition)
179 point.x = this.maxRightPosition;
180 this.slider.style.left = point.x + 'px';
181 this.setFilmPosition(point);
182 };
183
184 FilmSlider.prototype.setFilmPosition = function(point) {
185 this.film.style.left = point.x / this.sliderSpeedRatio + 'px';
186 };
187
188 FilmSlider.prototype.getSliderPosition = function() {
189 var x = parseInt(this.slider.style.left);
190 var y = parseInt(this.slider.style.top);
191 var p = new Point(x, y);
192 return p;
193 };
194
195 FilmSlider.prototype.getFilmPosition = function() {
196 var x = parseInt(this.film.style.left);
197 var y = parseInt(this.film.style.top);
198 var p = new Point(x, y);
199 return p;
200 };
201
202 FilmSlider.prototype.loadSibling = function(previous) {
203 var slide = null;
204 if (previous) {
205 slide = this.selectedSlide.parentNode.previousSibling;
206 if (slide && slide.nodeType==3)
207 slide = slide.previousSibling;
208 }
209 else {
210 slide = this.selectedSlide.parentNode.nextSibling;
211 if (slide && slide.nodeType==3)
212 slide = slide.nextSibling;
213 }
214
215 if (!slide)
216 return;
217 else {
218 var target = slide.getElementsByTagName('a')[0];
219 raiseMouseEvent(target, 'click');
220 var index = parseInt(target.getAttribute('portfolio:position'));
221 this.centerSlide(index);
222 }
223 };
224
225 FilmSlider.prototype.addEventListeners = function() {
226 var thisSlider = this;
227 addListener(window, 'resize', function(evt){thisSlider.resizeSlider(evt);});
228 addListener(this.filmBar, 'click', function(evt){thisSlider.thumbnailClickHandler(evt);});
229 addListener(this.toolbar, 'click', function(evt){thisSlider.toolbarClickHandler(evt);});
230 addListener(window, 'load', function(evt){thisSlider.fitToScreen(evt);});
231
232 // dd listeners
233 addListener(this.slider, 'mousedown', this.ddHandlers['down']);
234 if(browser.isDOM2Event){
235 if (browser.isAppleWebKit) {
236 this.filmBar.addEventListener('mousewheel', function(evt){thisSlider.mouseWheelHandler(evt);}, false);
237 }
238 else {
239 addListener(this.filmBar, 'DOMMouseScroll', function(evt){thisSlider.mouseWheelHandler(evt);});
240 }
241 }
242 else if (browser.isIE6up) {
243 addListener(this.filmBar, 'mousewheel', function(evt){thisSlider.mouseWheelHandler(evt);});
244 }
245
246 addListener(document, 'keydown', function(evt){thisSlider.keyDownHandler(evt);});
247 addListener(document, 'keypress', function(evt){thisSlider.keyPressHandler(evt);});
248 };
249
250
251 FilmSlider.prototype.mouseDownHandler = function(evt) {
252 this.initialClickPoint = new Point(evt.clientX, evt.clientY);
253 this.initialPosition = this.getSliderPosition();
254 this.dragInProgress = true;
255 addListener(document, 'mousemove', this.ddHandlers['move']);
256 addListener(document, 'mouseup', this.ddHandlers['up']);
257 addListener(document.body, 'mouseout', this.ddHandlers['out'])
258
259 };
260
261
262 FilmSlider.prototype.mouseMoveHandler = function(evt) {
263 if(!this.dragInProgress)
264 return;
265
266 clearSelection();
267 evt = getEventObject(evt);
268 var currentPoint = new Point(evt.clientX, evt.clientY);
269 var displacement = currentPoint.diff(this.initialClickPoint);
270 this.setSliderPosition(this.initialPosition.add(displacement));
271 };
272
273 FilmSlider.prototype.mouseUpHandler = function(evt) {
274 this.dragInProgress = false;
275 evt = getEventObject(evt);
276 this.mouseMoveHandler(evt);
277 };
278
279
280 FilmSlider.prototype.mouseOutHandler = function(evt) {
281 evt = getEventObject(evt);
282 var x = evt.clientX;
283 var y = evt.clientY;
284 if (x < 0 ||
285 x > this.winSize['width'] ||
286 y < 0 ||
287 y > this.winSize['height']
288 ){
289 this.mouseUpHandler(evt);
290 }
291 };
292
293 FilmSlider.prototype.thumbnailClickHandler = function(evt) {
294 var target = getTargetedObject(evt);
295 while (target.tagName != 'A' && target != this.filmBar)
296 target = target.parentNode;
297 if (target.tagName != 'A')
298 return;
299 else {
300 if (this.viewMode == 'full') {
301 this.mosaique.unload();
302 this.mosaique = null;
303 this.viewMode = 'medium';
304 }
305 disableDefault(evt);
306 disablePropagation(evt);
307 target.blur();
308 history.pushState(target.href, '', target.href);
309
310 var imgBaseUrl = target.href;
311 var canonicalImgUrl;
312 if (this.ctxUrlTranslation[0])
313 canonicalImgUrl = imgBaseUrl.replace(this.ctxUrlTranslation[0],
314 this.ctxUrlTranslation[1]);
315 else
316 canonicalImgUrl = imgBaseUrl;
317
318 var ajaxUrl = imgBaseUrl + '/photo_view_ajax';
319 var thisFS = this;
320
321 //this.pendingImage.src = canonicalImgUrl + '/getResizedImage?size=600';
322 var thumbnail = target.getElementsByTagName('IMG')[0];
323 var bestFitSize = this.getBestFitSize(thumbnail.height/thumbnail.width);
324 this.pendingImage.src = canonicalImgUrl + '/getResizedImage?size=' + bestFitSize;
325
326 // update buttons
327 var fullScreenLink = this.buttons['full_screen'].parentNode;
328 fullScreenLink.href = canonicalImgUrl + '/zoom_view';
329
330 var toggleSelectionBtn = this.buttons['toggle_selection'];
331 var toggleSelectionLink = toggleSelectionBtn.parentNode;
332 this.selectedSlideInSelection = (target.className=='selected');
333 if (this.selectedSlideInSelection) {
334 toggleSelectionBtn.src = portal_url() + '/unselect_flag_btn.gif';
335 toggleSelectionBtn.alt = toggleSelectionLink.title = 'Retirer de la sélection';
336 toggleSelectionLink.href = canonicalImgUrl + '/remove_to_selection';
337 }
338 else {
339 toggleSelectionBtn.src = portal_url() + '/select_flag_btn.gif';
340 toggleSelectionBtn.alt = toggleSelectionLink.title = 'Ajouter à la sélection';
341 toggleSelectionLink.href = canonicalImgUrl + '/add_to_selection';
342 }
343
344 var showBuyableButtonLink = this.buttons['show_buyable'].parentNode;
345 showBuyableButtonLink.href = canonicalImgUrl + '/get_slide_buyable_items';
346 this.cartSlide.innerHTML = '';
347 this.cartSlide.style.visibility='hidden';
348
349
350 var metadataButton = this.buttons['edit_metadata']
351 if (metadataButton) {
352 var metadataEditLink = metadataButton.parentNode;
353 metadataEditLink.href = canonicalImgUrl + '/photo_edit_form'
354 }
355
356
357 var req = new XMLHttpRequest();
358 req.onreadystatechange = function() {
359 switch (req.readyState) {
360 case 1 :
361 showProgressImage();
362 break;
363 case 2 :
364 try {
365 if (! isTextMime.exec(req.getResponseHeader('Content-Type'))) {
366 req.onreadystatechange = null;
367 req.abort();
368 hideProgressImage();
369 window.location.href = thisFS._fallBackUrl;
370 }
371 }
372 catch(e){}
373 break;
374 case 4 :
375 hideProgressImage();
376 if (req.status == '200')
377 thisFS.populateViewer(req);
378 else
379 //window.location.href = target.href;
380 console.error(ajaxUrl);
381
382 };
383 };
384
385 req.open("GET", ajaxUrl, true);
386 req.send(null);
387
388 // update old displayed slide className
389 var className = this.selectedSlide.className;
390 var classes = className.split(' ');
391 var newClasses = new Array();
392 var name;
393
394 for (i in classes) {
395 name = classes[i];
396 if (name == 'displayed')
397 continue;
398 else
399 newClasses.push(name);
400 }
401
402 this.selectedSlide.className = newClasses.join(' ')
403
404 // hightlight new displayed slide
405 this.selectedSlide = target;
406 className = this.selectedSlide.className;
407 classes = className.split(' ');
408 classes.push('displayed');
409 this.selectedSlide.className = classes.join(' ');
410 }
411 };
412
413 FilmSlider.prototype.toolbarClickHandler = function(evt) {
414 var target = getTargetedObject(evt);
415 if(target.tagName == 'IMG' && target.getAttribute('name')){
416 switch(target.getAttribute('name')) {
417 case 'previous' :
418 disableDefault(evt);
419 disablePropagation(evt);
420 var button = target;
421 var link = button.parentNode;
422 link.blur();
423 this.loadSibling(true);
424 break;
425 case 'next' :
426 disableDefault(evt);
427 disablePropagation(evt);
428 var button = target;
429 var link = button.parentNode;
430 link.blur();
431 this.loadSibling(false);
432 break;
433 case 'full_screen':
434 disableDefault(evt);
435 disablePropagation(evt);
436 target.parentNode.blur();
437 if (this.viewMode == 'full') {
438 this.mosaique.unload();
439 this.mosaique = null;
440 this.viewMode = 'medium';
441 return;
442 }
443 var main = document.getElementById('photo_viewer');
444 var url = target.parentNode.href;
445 url = url.substring(0, url.length - '/zoom_view'.length);
446 var margins = {'top':0, 'right':-1, 'bottom':0, 'left':0};
447 this.mosaique = new Mosaique(main, url, margins);
448 this.viewMode = 'full';
449 break;
450
451 case 'toggle_selection':
452 disableDefault(evt);
453 disablePropagation(evt);
454 var button = target;
455 var link = button.parentNode;
456 link.blur();
457
458 var req = new XMLHttpRequest();
459 var url = link.href;
460 req.open("POST", url, true);
461 req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded;charset=utf-8");
462 req.send("ajax=1");
463
464 // toggle button
465 var parts = url.split('/');
466 var canonicalImgUrl = parts.slice(0, parts.length-1).join('/');
467
468 if (isAddToSelection.test(url)) {
469 button.src = portal_url() + '/unselect_flag_btn.gif';
470 button.alt = link.title = 'Retirer de la sélection';
471 link.href = canonicalImgUrl + '/remove_to_selection';
472 this.selectedSlide.className = 'selected displayed';
473 this.image.parentNode.className = 'selected';
474 this.selectedSlideInSelection = true;
475 }
476 else {
477 button.src = portal_url() + '/select_flag_btn.gif';
478 button.alt = link.title = 'Ajouter à la sélection';
479 link.href = canonicalImgUrl + '/add_to_selection';
480 this.selectedSlide.className = 'displayed';
481 this.image.parentNode.className = '';
482 this.selectedSlideInSelection = false;
483 }
484 break;
485
486 case 'show_buyable':
487 disableDefault(evt);
488 disablePropagation(evt);
489 var button = target;
490 var link = button.parentNode;
491 link.blur();
492 var slide = this.cartSlide;
493 slide.innerHTML = '';
494 slide.style.visibility = 'visible';
495 var cw = new CartWidget(slide, link.href);
496 cw.onCancel = function() {
497 CartWidget.prototype.onCancel.apply(this);
498 slide.style.visibility = 'hidden';
499 };
500 cw.onAfterConfirm = function() {
501 slide.style.visibility = 'hidden';
502 };
503 break;
504
505
506
507
508 /*
509 case 'edit_metadata' :
510 disableDefault(evt);
511 disablePropagation(evt);
512 target.blur();
513 if (this.viewMode == 'full') {
514 this.mosaique.unload();
515 this.mosaique = null;
516 this.viewMode = 'medium';
517 return;
518 }
519 var fi = new FragmentImporter(absolute_url());
520 fi.useMacro('metadata_edit_form_macros', 'iptc', 'image_metadata');
521 break;
522 */
523 }
524 }
525 };
526
527
528 if(browser.isDOM2Event){
529 if (browser.isAppleWebKit) {
530 FilmSlider.prototype.mouseWheelHandler = function(evt) {
531 disableDefault(evt);
532 var pos = this.getSliderPosition();
533 pos.x -= evt.wheelDelta / 40;
534 this.setSliderPosition(pos);
535 };
536 }
537 else {
538 FilmSlider.prototype.mouseWheelHandler = function(evt) {
539 disableDefault(evt);
540 var pos = this.getSliderPosition();
541 pos.x += evt.detail * 3;
542 this.setSliderPosition(pos);
543 };
544 }
545 }
546 else if (browser.isIE6up) {
547 FilmSlider.prototype.mouseWheelHandler = function() {
548 var evt = window.event;
549 evt.returnValue = false;
550 var pos = this.getSliderPosition();
551 pos.x -= evt.wheelDelta / 40;
552 this.setSliderPosition(pos);
553 };
554 }
555
556 FilmSlider.prototype.keyDownHandler = function(evt) {
557 var evt = getEventObject(evt);
558 switch (evt.keyCode) {
559 case keyLeft :
560 this.loadSibling(true);
561 break;
562 case keyRight :
563 this.loadSibling(false);
564 break;
565 default:
566 return;
567 }
568 };
569
570
571 FilmSlider.prototype.keyPressHandler = function(evt) {
572 var target = getTargetedObject(evt);
573 if (target.tagName == 'INPUT' || target.tagName== 'TEXTAREA')
574 return;
575 var evt = getEventObject(evt);
576 var charPress = String.fromCharCode((evt.keyCode) ? evt.keyCode : evt.which);
577 switch(charPress) {
578 case 'f':
579 case 'F':
580 raiseMouseEvent(this.buttons['full_screen'], 'click');
581 break;
582 }
583 };
584
585 FilmSlider.prototype.populateViewer = function(req) {
586 var elements = req.responseXML.documentElement.childNodes;
587 for(var i=0 ; i < elements.length ; i++ ) {
588 element = elements[i];
589 switch (element.nodeName) {
590 case 'fragment' :
591 var dest = document.getElementById(element.getAttribute('id'));
592 dest.innerHTML = element.firstChild.nodeValue;
593 break;
594 case 'imageattributes' :
595 var link = this.buttons['back_to_portfolio'].parentNode;
596 link.href = element.getAttribute('backToContextUrl');
597 link = this.buttons['show_buyable'].parentNode;
598 var buyable = element.getAttribute('buyable');
599 if(buyable == 'True')
600 link.className = null;
601 else if(buyable == 'False')
602 link.className = 'hidden';
603 this.image.alt = element.getAttribute('alt');
604 this.lastBCElement.href = element.getAttribute('lastBcUrl');
605 this.lastBCElement.innerHTML = element.getAttribute('img_id');
606 break;
607 }
608 }
609 };
610
611 FilmSlider.prototype.refreshImage = function() {
612 this.image.style.visibility = 'hidden';
613 this.image.src = this.pendingImage.src;
614 this.image.width = this.pendingImage.width;
615 this.image.height = this.pendingImage.height;
616 this.image.style.visibility = 'visible';
617 if (this.selectedSlideInSelection)
618 this.image.parentNode.className = 'selected';
619 else
620 this.image.parentNode.className = '';
621 };
622
623 FilmSlider.prototype.startSlideShow = function() {
624 this.slideShowSlide = this.pendingSlideShowSlide = this.selectedSlide;
625 return this.slideShowSlide.href;
626 };
627
628 FilmSlider.prototype.slideShowNext = function() {
629 var nextSlide = this.slideShowSlide.parentNode.nextSibling;
630 if (nextSlide && nextSlide.nodeType==3)
631 nextSlide = nextSlide.nextSibling;
632
633 if (nextSlide) {
634 nextSlide = nextSlide.getElementsByTagName('a')[0];
635 this.pendingSlideShowSlide = nextSlide;
636 return this.pendingSlideShowSlide.href;
637 }
638 else {
639 var row = this.slideShowSlide.parentNode.parentNode;
640 var first = row.firstChild;
641 if (first.nodeType==3)
642 first = first.nextSibling;
643 this.pendingSlideShowSlide = first.getElementsByTagName('a')[0];
644 return this.pendingSlideShowSlide.href;
645 }
646 };
647
648 FilmSlider.prototype.slideShowPrevious = function() {
649 var previousSlide = this.slideShowSlide.parentNode.previousSibling;
650 if (previousSlide && previousSlide.nodeType==3)
651 previousSlide = previousSlide.previousSibling;
652
653 if (previousSlide) {
654 previousSlide = previousSlide.getElementsByTagName('a')[0];
655 this.pendingSlideShowSlide = previousSlide;
656 return this.pendingSlideShowSlide.href;
657 }
658 else {
659 var row = this.slideShowSlide.parentNode.parentNode;
660 var last = row.lastChild;
661 if (last.nodeType==3)
662 last = last.previousSibling;
663 this.pendingSlideShowSlide = last.getElementsByTagName('a')[0];
664 return this.pendingSlideShowSlide.href;
665 }
666 };
667
668 FilmSlider.prototype.slideShowImageLoaded = function() {
669 this.slideShowSlide = this.pendingSlideShowSlide;
670 };
671
672 FilmSlider.prototype.stopSlideShow = function() {
673 raiseMouseEvent(this.slideShowSlide, 'click');
674 var index = parseInt(this.selectedSlide.getAttribute('portfolio:position'));
675 this.centerSlide(index);
676 };
677
678
679 /* UTILS */
680 function Point(x, y) {
681 this.x = Math.round(x);
682 this.y = Math.round(y);
683 }
684 Point.prototype.diff = function(point) { return new Point(this.x - point.x, this.y - point.y); };
685 Point.prototype.add = function(point) { return new Point(this.x + point.x, this.y + point.y); };
686 Point.prototype.mul = function(k) { return new Point(this.x * k, this.y *k)};
687 Point.prototype.toString = function() { return "(" + String(this.x) + ", " + String(this.y) + ")"; };
688
689 })();