Merge branch 'MDL-59629-master' of git://github.com/peterRd/moodle
[moodle.git] / mod / assign / feedback / editpdf / yui / src / editor / js / editor.js
1 // This file is part of Moodle - http://moodle.org/
2 //
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
12 //
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15 /* eslint-disable no-unused-vars */
16 /* global SELECTOR, TOOLSELECTOR, AJAXBASE, COMMENTCOLOUR, ANNOTATIONCOLOUR, AJAXBASEPROGRESS, CLICKTIMEOUT */
18 /**
19  * Provides an in browser PDF editor.
20  *
21  * @module moodle-assignfeedback_editpdf-editor
22  */
24 /**
25  * EDITOR
26  * This is an in browser PDF editor.
27  *
28  * @namespace M.assignfeedback_editpdf
29  * @class editor
30  * @constructor
31  * @extends Y.Base
32  */
33 var EDITOR = function() {
34     EDITOR.superclass.constructor.apply(this, arguments);
35 };
36 EDITOR.prototype = {
38     /**
39      * The dialogue used for all action menu displays.
40      *
41      * @property type
42      * @type M.core.dialogue
43      * @protected
44      */
45     dialogue: null,
47     /**
48      * The panel used for all action menu displays.
49      *
50      * @property type
51      * @type Y.Node
52      * @protected
53      */
54     panel: null,
56     /**
57      * The number of pages in the pdf.
58      *
59      * @property pagecount
60      * @type Number
61      * @protected
62      */
63     pagecount: 0,
65     /**
66      * The active page in the editor.
67      *
68      * @property currentpage
69      * @type Number
70      * @protected
71      */
72     currentpage: 0,
74     /**
75      * A list of page objects. Each page has a list of comments and annotations.
76      *
77      * @property pages
78      * @type array
79      * @protected
80      */
81     pages: [],
83     /**
84      * The reported status of the document.
85      *
86      * @property documentstatus
87      * @type int
88      * @protected
89      */
90     documentstatus: 0,
92     /**
93      * The yui node for the loading icon.
94      *
95      * @property loadingicon
96      * @type Node
97      * @protected
98      */
99     loadingicon: null,
101     /**
102      * Image object of the current page image.
103      *
104      * @property pageimage
105      * @type Image
106      * @protected
107      */
108     pageimage: null,
110     /**
111      * YUI Graphic class for drawing shapes.
112      *
113      * @property graphic
114      * @type Graphic
115      * @protected
116      */
117     graphic: null,
119     /**
120      * Info about the current edit operation.
121      *
122      * @property currentedit
123      * @type M.assignfeedback_editpdf.edit
124      * @protected
125      */
126     currentedit: new M.assignfeedback_editpdf.edit(),
128     /**
129      * Current drawable.
130      *
131      * @property currentdrawable
132      * @type M.assignfeedback_editpdf.drawable|false
133      * @protected
134      */
135     currentdrawable: false,
137     /**
138      * Current drawables.
139      *
140      * @property drawables
141      * @type array(M.assignfeedback_editpdf.drawable)
142      * @protected
143      */
144     drawables: [],
146     /**
147      * Current comment when the comment menu is open.
148      * @property currentcomment
149      * @type M.assignfeedback_editpdf.comment
150      * @protected
151      */
152     currentcomment: null,
154     /**
155      * Current annotation when the select tool is used.
156      * @property currentannotation
157      * @type M.assignfeedback_editpdf.annotation
158      * @protected
159      */
160     currentannotation: null,
162     /**
163      * Track the previous annotation so we can remove selection highlights.
164      * @property lastannotation
165      * @type M.assignfeedback_editpdf.annotation
166      * @protected
167      */
168     lastannotation: null,
170     /**
171      * Last selected annotation tool
172      * @property lastannotationtool
173      * @type String
174      * @protected
175      */
176     lastannotationtool: "pen",
178     /**
179      * The users comments quick list
180      * @property quicklist
181      * @type M.assignfeedback_editpdf.quickcommentlist
182      * @protected
183      */
184     quicklist: null,
186     /**
187      * The search comments window.
188      * @property searchcommentswindow
189      * @type M.core.dialogue
190      * @protected
191      */
192     searchcommentswindow: null,
195     /**
196      * The selected stamp picture.
197      * @property currentstamp
198      * @type String
199      * @protected
200      */
201     currentstamp: null,
203     /**
204      * The stamps.
205      * @property stamps
206      * @type Array
207      * @protected
208      */
209     stamps: [],
211     /**
212      * Prevent new comments from appearing
213      * immediately after clicking off a current
214      * comment
215      * @property editingcomment
216      * @type Boolean
217      * @public
218      */
219     editingcomment: false,
221     /**
222      * Should inactive comments be collapsed?
223      *
224      * @property collapsecomments
225      * @type Boolean
226      * @public
227      */
228     collapsecomments: true,
230     /**
231      * Called during the initialisation process of the object.
232      * @method initializer
233      */
234     initializer: function() {
235         var link;
237         link = Y.one('#' + this.get('linkid'));
239         if (link) {
240             link.on('click', this.link_handler, this);
241             link.on('key', this.link_handler, 'down:13', this);
243             // We call the amd module to see if we can take control of the review panel.
244             require(['mod_assign/grading_review_panel'], function(ReviewPanelManager) {
245                 var panelManager = new ReviewPanelManager();
247                 var panel = panelManager.getReviewPanel('assignfeedback_editpdf');
248                 if (panel) {
249                     panel = Y.one(panel);
250                     panel.empty();
251                     link.ancestor('.fitem').hide();
252                     this.open_in_panel(panel);
253                 }
254                 this.currentedit.start = false;
255                 this.currentedit.end = false;
256                 if (!this.get('readonly')) {
257                     this.quicklist = new M.assignfeedback_editpdf.quickcommentlist(this);
258                 }
259             }.bind(this));
261         }
262     },
264     /**
265      * Called to show/hide buttons and set the current colours/stamps.
266      * @method refresh_button_state
267      */
268     refresh_button_state: function() {
269         var button, currenttoolnode, imgurl, drawingregion, stampimgurl, drawingcanvas;
271         // Initalise the colour buttons.
272         button = this.get_dialogue_element(SELECTOR.COMMENTCOLOURBUTTON);
274         imgurl = M.util.image_url('background_colour_' + this.currentedit.commentcolour, 'assignfeedback_editpdf');
275         button.one('img').setAttribute('src', imgurl);
277         if (this.currentedit.commentcolour === 'clear') {
278             button.one('img').setStyle('borderStyle', 'dashed');
279         } else {
280             button.one('img').setStyle('borderStyle', 'solid');
281         }
283         button = this.get_dialogue_element(SELECTOR.ANNOTATIONCOLOURBUTTON);
284         imgurl = M.util.image_url('colour_' + this.currentedit.annotationcolour, 'assignfeedback_editpdf');
285         button.one('img').setAttribute('src', imgurl);
287         currenttoolnode = this.get_dialogue_element(TOOLSELECTOR[this.currentedit.tool]);
288         currenttoolnode.addClass('assignfeedback_editpdf_selectedbutton');
289         currenttoolnode.setAttribute('aria-pressed', 'true');
290         drawingregion = this.get_dialogue_element(SELECTOR.DRAWINGREGION);
291         drawingregion.setAttribute('data-currenttool', this.currentedit.tool);
293         button = this.get_dialogue_element(SELECTOR.STAMPSBUTTON);
294         stampimgurl = this.get_stamp_image_url(this.currentedit.stamp);
295         button.one('img').setAttrs({'src': stampimgurl,
296                                     'height': '16',
297                                     'width': '16'});
299         drawingcanvas = this.get_dialogue_element(SELECTOR.DRAWINGCANVAS);
300         switch (this.currentedit.tool) {
301             case 'drag':
302                 drawingcanvas.setStyle('cursor', 'move');
303                 break;
304             case 'highlight':
305                 drawingcanvas.setStyle('cursor', 'text');
306                 break;
307             case 'select':
308                 drawingcanvas.setStyle('cursor', 'default');
309                 break;
310             case 'stamp':
311                 drawingcanvas.setStyle('cursor', 'url(' + stampimgurl + '), crosshair');
312                 break;
313             default:
314                 drawingcanvas.setStyle('cursor', 'crosshair');
315         }
316     },
318     /**
319      * Called to get the bounds of the drawing region.
320      * @method get_canvas_bounds
321      */
322     get_canvas_bounds: function() {
323         var canvas = this.get_dialogue_element(SELECTOR.DRAWINGCANVAS),
324             offsetcanvas = canvas.getXY(),
325             offsetleft = offsetcanvas[0],
326             offsettop = offsetcanvas[1],
327             width = parseInt(canvas.getStyle('width'), 10),
328             height = parseInt(canvas.getStyle('height'), 10);
330         return new M.assignfeedback_editpdf.rect(offsetleft, offsettop, width, height);
331     },
333     /**
334      * Called to translate from window coordinates to canvas coordinates.
335      * @method get_canvas_coordinates
336      * @param M.assignfeedback_editpdf.point point in window coordinats.
337      */
338     get_canvas_coordinates: function(point) {
339         var bounds = this.get_canvas_bounds(),
340             newpoint = new M.assignfeedback_editpdf.point(point.x - bounds.x, point.y - bounds.y);
342         bounds.x = bounds.y = 0;
344         newpoint.clip(bounds);
345         return newpoint;
346     },
348     /**
349      * Called to translate from canvas coordinates to window coordinates.
350      * @method get_window_coordinates
351      * @param M.assignfeedback_editpdf.point point in window coordinats.
352      */
353     get_window_coordinates: function(point) {
354         var bounds = this.get_canvas_bounds(),
355             newpoint = new M.assignfeedback_editpdf.point(point.x + bounds.x, point.y + bounds.y);
357         return newpoint;
358     },
360     /**
361      * Open the edit-pdf editor in the panel in the page instead of a popup.
362      * @method open_in_panel
363      */
364     open_in_panel: function(panel) {
365         var drawingcanvas, drawingregion;
367         this.panel = panel;
368         panel.append(this.get('body'));
369         panel.addClass(CSS.DIALOGUE);
371         this.loadingicon = this.get_dialogue_element(SELECTOR.LOADINGICON);
373         drawingcanvas = this.get_dialogue_element(SELECTOR.DRAWINGCANVAS);
374         this.graphic = new Y.Graphic({render: drawingcanvas});
376         drawingregion = this.get_dialogue_element(SELECTOR.DRAWINGREGION);
377         drawingregion.on('scroll', this.move_canvas, this);
379         if (!this.get('readonly')) {
380             drawingcanvas.on('gesturemovestart', this.edit_start, null, this);
381             drawingcanvas.on('gesturemove', this.edit_move, null, this);
382             drawingcanvas.on('gesturemoveend', this.edit_end, null, this);
384             this.refresh_button_state();
385         }
387         this.start_generation();
388     },
390     /**
391      * Called to open the pdf editing dialogue.
392      * @method link_handler
393      */
394     link_handler: function(e) {
395         var drawingcanvas, drawingregion;
396         var resize = true;
397         e.preventDefault();
399         if (!this.dialogue) {
400             this.dialogue = new M.core.dialogue({
401                 headerContent: this.get('header'),
402                 bodyContent: this.get('body'),
403                 footerContent: this.get('footer'),
404                 modal: true,
405                 width: '840px',
406                 visible: false,
407                 draggable: true
408             });
410             // Add custom class for styling.
411             this.dialogue.get('boundingBox').addClass(CSS.DIALOGUE);
413             this.loadingicon = this.get_dialogue_element(SELECTOR.LOADINGICON);
415             drawingcanvas = this.get_dialogue_element(SELECTOR.DRAWINGCANVAS);
416             this.graphic = new Y.Graphic({render: drawingcanvas});
418             drawingregion = this.get_dialogue_element(SELECTOR.DRAWINGREGION);
419             drawingregion.on('scroll', this.move_canvas, this);
421             if (!this.get('readonly')) {
422                 drawingcanvas.on('gesturemovestart', this.edit_start, null, this);
423                 drawingcanvas.on('gesturemove', this.edit_move, null, this);
424                 drawingcanvas.on('gesturemoveend', this.edit_end, null, this);
426                 this.refresh_button_state();
427             }
429             this.start_generation();
430             drawingcanvas.on('windowresize', this.resize, this);
432             resize = false;
433         }
434         this.dialogue.centerDialogue();
435         this.dialogue.show();
437         // Redraw when the dialogue is moved, to ensure the absolute elements are all positioned correctly.
438         this.dialogue.dd.on('drag:end', this.redraw, this);
439         if (resize) {
440             this.resize(); // When re-opening the dialog call redraw, to make sure the size + layout is correct.
441         }
442     },
444     /**
445      * Called to load the information and annotations for all pages.
446      *
447      * @method start_generation
448      */
449     start_generation: function() {
450         this.poll_document_conversion_status();
451     },
453     /**
454      * Poll the current document conversion status and start the next step
455      * in the process.
456      *
457      * @method poll_document_conversion_status
458      */
459     poll_document_conversion_status: function() {
460         if (this.get('destroyed')) {
461             return;
462         }
464         Y.io(AJAXBASE, {
465             method: 'get',
466             context: this,
467             sync: false,
468             data: {
469                 sesskey: M.cfg.sesskey,
470                 action: 'pollconversions',
471                 userid: this.get('userid'),
472                 attemptnumber: this.get('attemptnumber'),
473                 assignmentid: this.get('assignmentid'),
474                 readonly: this.get('readonly') ? 1 : 0
475             },
476             on: {
477                 success: function(tid, response) {
478                     var data = this.handle_response_data(response),
479                         poll = false;
480                     if (data) {
481                         this.documentstatus = data.status;
482                         if (data.status === 0) {
483                             // The combined document is still waiting for input to be ready.
484                             poll = true;
486                         } else if (data.status === 1 || data.status === 3) {
487                             // The combine document is ready for conversion into a single PDF.
488                             poll = true;
490                         } else if (data.status === 2 || data.status === -1) {
491                             // The combined PDF is ready.
492                             // We now know the page count and can convert it to a set of images.
493                             this.pagecount = data.pagecount;
495                             if (data.pageready == data.pagecount) {
496                                 this.prepare_pages_for_display(data);
497                             } else {
498                                 // Some pages are not ready yet.
499                                 // Note: We use a different polling process here which does not block.
500                                 this.update_page_load_progress();
502                                 // Fetch the images for the combined document.
503                                 this.start_document_to_image_conversion();
504                             }
505                         }
507                         if (poll) {
508                             // Check again in 1 second.
509                             Y.later(1000, this, this.poll_document_conversion_status);
510                         }
511                     }
512                 },
513                 failure: function(tid, response) {
514                     return new M.core.exception(response.responseText);
515                 }
516             }
517         });
518     },
520     /**
521      * Spwan the PDF to Image conversion on the server.
522      *
523      * @method get_images_for_documents
524      */
525     start_document_to_image_conversion: function() {
526         if (this.get('destroyed')) {
527             return;
528         }
529         Y.io(AJAXBASE, {
530             method: 'get',
531             context: this,
532             sync: false,
533             data: {
534                 sesskey: M.cfg.sesskey,
535                 action: 'pollconversions',
536                 userid: this.get('userid'),
537                 attemptnumber: this.get('attemptnumber'),
538                 assignmentid: this.get('assignmentid'),
539                 readonly: this.get('readonly') ? 1 : 0
540             },
541             on: {
542                 success: function(tid, response) {
543                     var data = this.handle_response_data(response);
544                     if (data) {
545                         this.documentstatus = data.status;
546                         if (data.status === 2) {
547                             // The pages are ready. Add all of the annotations to them.
548                             this.prepare_pages_for_display(data);
549                         }
550                     }
551                 },
552                 failure: function(tid, response) {
553                     return new M.core.exception(response.responseText);
554                 }
555             }
556         });
557     },
559     /**
560      * Display an error in a small part of the page (don't block everything).
561      *
562      * @param string The error text.
563      * @param boolean dismissable Not critical messages can be removed after a short display.
564      * @protected
565      * @method warning
566      */
567     warning: function(message, dismissable) {
568         var icontemplate = this.get_dialogue_element(SELECTOR.ICONMESSAGECONTAINER);
569         var warningregion = this.get_dialogue_element(SELECTOR.WARNINGMESSAGECONTAINER);
570         var delay = 15, duration = 1;
571         var messageclasses = 'assignfeedback_editpdf_warningmessages alert alert-warning';
572         if (dismissable) {
573             delay = 4;
574             messageclasses = 'assignfeedback_editpdf_warningmessages alert alert-info';
575         }
576         var warningelement = Y.Node.create('<div class="' + messageclasses + '" role="alert"></div>');
578         // Copy info icon template.
579         warningelement.append(icontemplate.one('*').cloneNode());
581         // Append the message.
582         warningelement.append(message);
584         // Add the entire warning to the container.
585         warningregion.prepend(warningelement);
587         // Remove the message after a short delay.
588         warningelement.transition(
589             {
590                 duration: duration,
591                 delay: delay,
592                 opacity: 0
593             },
594             function() {
595                 warningelement.remove();
596             }
597         );
598     },
600     /**
601      * The info about all pages in the pdf has been returned.
602      *
603      * @param string The ajax response as text.
604      * @protected
605      * @method prepare_pages_for_display
606      */
607     prepare_pages_for_display: function(data) {
608         var i, j, comment, error, annotation, readonly;
610         if (!data.pagecount) {
611             if (this.dialogue) {
612                 this.dialogue.hide();
613             }
614             // Display alert dialogue.
615             error = new M.core.alert({message: M.util.get_string('cannotopenpdf', 'assignfeedback_editpdf')});
616             error.show();
617             return;
618         }
620         this.pages = data.pages;
622         for (i = 0; i < this.pages.length; i++) {
623             for (j = 0; j < this.pages[i].comments.length; j++) {
624                 comment = this.pages[i].comments[j];
625                 this.pages[i].comments[j] = new M.assignfeedback_editpdf.comment(this,
626                                                                                  comment.gradeid,
627                                                                                  comment.pageno,
628                                                                                  comment.x,
629                                                                                  comment.y,
630                                                                                  comment.width,
631                                                                                  comment.colour,
632                                                                                  comment.rawtext);
633             }
634             for (j = 0; j < this.pages[i].annotations.length; j++) {
635                 annotation = this.pages[i].annotations[j];
636                 this.pages[i].annotations[j] = this.create_annotation(annotation.type, annotation);
637             }
638         }
640         readonly = this.get('readonly');
641         if (!readonly && data.partial) {
642             // Warn about non converted files, but only for teachers.
643             this.warning(M.util.get_string('partialwarning', 'assignfeedback_editpdf'), false);
644         }
646         // Update the ui.
647         if (this.quicklist) {
648             this.quicklist.load();
649         }
650         this.setup_navigation();
651         this.setup_toolbar();
652         this.change_page();
653     },
655     /**
656      * Fetch the page images.
657      *
658      * @method update_page_load_progress
659      */
660     update_page_load_progress: function() {
661         if (this.get('destroyed')) {
662             return;
663         }
664         var checkconversionstatus,
665             ajax_error_total = 0,
666             progressbar = this.get_dialogue_element(SELECTOR.PROGRESSBARCONTAINER + ' .bar');
668         if (!progressbar) {
669             return;
670         }
672         // If pages are not loaded, check PDF conversion status for the progress bar.
673         checkconversionstatus = {
674             method: 'get',
675             context: this,
676             sync: false,
677             data: {
678                 sesskey: M.cfg.sesskey,
679                 action: 'conversionstatus',
680                 userid: this.get('userid'),
681                 attemptnumber: this.get('attemptnumber'),
682                 assignmentid: this.get('assignmentid')
683             },
684             on: {
685                 success: function(tid, response) {
686                     if (this.get('destroyed')) {
687                         return;
688                     }
689                     ajax_error_total = 0;
691                     var progress = 0;
692                     var progressbar = this.get_dialogue_element(SELECTOR.PROGRESSBARCONTAINER + ' .bar');
693                     if (progressbar) {
694                         // Calculate progress.
695                         progress = (response.response / this.pagecount) * 100;
696                         progressbar.setStyle('width', progress + '%');
697                         progressbar.ancestor(SELECTOR.PROGRESSBARCONTAINER).setAttribute('aria-valuenow', progress);
699                         if (progress < 100) {
700                             // Keep polling until all pages are generated.
701                             M.util.js_pending('checkconversionstatus');
702                             Y.later(1000, this, function() {
703                                 M.util.js_complete('checkconversionstatus');
704                                 Y.io(AJAXBASEPROGRESS, checkconversionstatus);
705                             });
706                         }
707                     }
708                 },
709                 failure: function(tid, response) {
710                     if (this.get('destroyed')) {
711                         return;
712                     }
713                     ajax_error_total = ajax_error_total + 1;
714                     // We only continue on error if the all pages were not generated,
715                     // and if the ajax call did not produce 5 errors in the row.
716                     if (this.pagecount === 0 && ajax_error_total < 5) {
717                         M.util.js_pending('checkconversionstatus');
718                         Y.later(1000, this, function() {
719                             M.util.js_complete('checkconversionstatus');
720                             Y.io(AJAXBASEPROGRESS, checkconversionstatus);
721                         });
722                     }
723                     return new M.core.exception(response.responseText);
724                 }
725             }
726         };
727         // We start the AJAX "generated page total number" call a second later to give a chance to
728         // the AJAX "combined pdf generation" call to clean the previous submission images.
729         M.util.js_pending('checkconversionstatus');
730         Y.later(1000, this, function() {
731             ajax_error_total = 0;
732             M.util.js_complete('checkconversionstatus');
733             Y.io(AJAXBASEPROGRESS, checkconversionstatus);
734         });
735     },
737     /**
738      * Handle response data.
739      *
740      * @method  handle_response_data
741      * @param   {object} response
742      * @return  {object}
743      */
744     handle_response_data: function(response) {
745         if (this.get('destroyed')) {
746             return;
747         }
748         var data;
749         try {
750             data = Y.JSON.parse(response.responseText);
751             if (data.error) {
752                 if (this.dialogue) {
753                     this.dialogue.hide();
754                 }
756                 new M.core.alert({
757                     message: M.util.get_string('cannotopenpdf', 'assignfeedback_editpdf'),
758                     visible: true
759                 });
760             } else {
761                 return data;
762             }
763         } catch (e) {
764             if (this.dialogue) {
765                 this.dialogue.hide();
766             }
768             new M.core.alert({
769                 title: M.util.get_string('cannotopenpdf', 'assignfeedback_editpdf'),
770                 visible: true
771             });
772         }
774         return;
775     },
777     /**
778      * Get the full pluginfile url for an image file - just given the filename.
779      *
780      * @public
781      * @method get_stamp_image_url
782      * @param string filename
783      */
784     get_stamp_image_url: function(filename) {
785         var urls = this.get('stampfiles'),
786             fullurl = '';
788         Y.Array.each(urls, function(url) {
789             if (url.indexOf(filename) > 0) {
790                 fullurl = url;
791             }
792         }, this);
794         return fullurl;
795     },
797     /**
798      * Attach listeners and enable the color picker buttons.
799      * @protected
800      * @method setup_toolbar
801      */
802     setup_toolbar: function() {
803         var toolnode,
804             commentcolourbutton,
805             annotationcolourbutton,
806             searchcommentsbutton,
807             expcolcommentsbutton,
808             currentstampbutton,
809             stampfiles,
810             picker,
811             filename;
813         searchcommentsbutton = this.get_dialogue_element(SELECTOR.SEARCHCOMMENTSBUTTON);
814         searchcommentsbutton.on('click', this.open_search_comments, this);
815         searchcommentsbutton.on('key', this.open_search_comments, 'down:13', this);
817         expcolcommentsbutton = this.get_dialogue_element(SELECTOR.EXPCOLCOMMENTSBUTTON);
818         expcolcommentsbutton.on('click', this.expandCollapseComments, this);
819         expcolcommentsbutton.on('key', this.expandCollapseComments, 'down:13', this);
821         if (this.get('readonly')) {
822             return;
823         }
824         this.disable_touch_scroll();
826         // Setup the tool buttons.
827         Y.each(TOOLSELECTOR, function(selector, tool) {
828             toolnode = this.get_dialogue_element(selector);
829             toolnode.on('click', this.handle_tool_button, this, tool);
830             toolnode.on('key', this.handle_tool_button, 'down:13', this, tool);
831             toolnode.setAttribute('aria-pressed', 'false');
832         }, this);
834         // Set the default tool.
836         commentcolourbutton = this.get_dialogue_element(SELECTOR.COMMENTCOLOURBUTTON);
837         picker = new M.assignfeedback_editpdf.colourpicker({
838             buttonNode: commentcolourbutton,
839             colours: COMMENTCOLOUR,
840             iconprefix: 'background_colour_',
841             callback: function(e) {
842                 var colour = e.target.getAttribute('data-colour');
843                 if (!colour) {
844                     colour = e.target.ancestor().getAttribute('data-colour');
845                 }
846                 this.currentedit.commentcolour = colour;
847                 this.handle_tool_button(e, "comment");
848             },
849             context: this
850         });
852         annotationcolourbutton = this.get_dialogue_element(SELECTOR.ANNOTATIONCOLOURBUTTON);
853         picker = new M.assignfeedback_editpdf.colourpicker({
854             buttonNode: annotationcolourbutton,
855             iconprefix: 'colour_',
856             colours: ANNOTATIONCOLOUR,
857             callback: function(e) {
858                 var colour = e.target.getAttribute('data-colour');
859                 if (!colour) {
860                     colour = e.target.ancestor().getAttribute('data-colour');
861                 }
862                 this.currentedit.annotationcolour = colour;
863                 if (this.lastannotationtool) {
864                     this.handle_tool_button(e, this.lastannotationtool);
865                 } else {
866                     this.handle_tool_button(e, "pen");
867                 }
868             },
869             context: this
870         });
872         stampfiles = this.get('stampfiles');
873         if (stampfiles.length <= 0) {
874             this.get_dialogue_element(TOOLSELECTOR.stamp).ancestor().hide();
875         } else {
876             filename = stampfiles[0].substr(stampfiles[0].lastIndexOf('/') + 1);
877             this.currentedit.stamp = filename;
878             currentstampbutton = this.get_dialogue_element(SELECTOR.STAMPSBUTTON);
880             picker = new M.assignfeedback_editpdf.stamppicker({
881                 buttonNode: currentstampbutton,
882                 stamps: stampfiles,
883                 callback: function(e) {
884                     var stamp = e.target.getAttribute('data-stamp'),
885                         filename;
887                     if (!stamp) {
888                         stamp = e.target.ancestor().getAttribute('data-stamp');
889                     }
890                     filename = stamp.substr(stamp.lastIndexOf('/'));
891                     this.currentedit.stamp = filename;
892                     this.handle_tool_button(e, "stamp");
893                 },
894                 context: this
895             });
896             this.refresh_button_state();
897         }
898     },
900     /**
901      * Change the current tool.
902      * @protected
903      * @method handle_tool_button
904      */
905     handle_tool_button: function(e, tool) {
906         var currenttoolnode;
908         e.preventDefault();
910         // Change style of the pressed button.
911         currenttoolnode = this.get_dialogue_element(TOOLSELECTOR[this.currentedit.tool]);
912         currenttoolnode.removeClass('assignfeedback_editpdf_selectedbutton');
913         currenttoolnode.setAttribute('aria-pressed', 'false');
914         this.currentedit.tool = tool;
916         if (tool !== "comment" && tool !== "select" && tool !== "drag" && tool !== "stamp") {
917             this.lastannotationtool = tool;
918         }
920         this.refresh_button_state();
921     },
923     /**
924      * JSON encode the current page data - stripping out drawable references which cannot be encoded.
925      * @protected
926      * @method stringify_current_page
927      * @return string
928      */
929     stringify_current_page: function() {
930         var comments = [],
931             annotations = [],
932             page,
933             i = 0;
935         for (i = 0; i < this.pages[this.currentpage].comments.length; i++) {
936             comments[i] = this.pages[this.currentpage].comments[i].clean();
937         }
938         for (i = 0; i < this.pages[this.currentpage].annotations.length; i++) {
939             annotations[i] = this.pages[this.currentpage].annotations[i].clean();
940         }
942         page = {comments: comments, annotations: annotations};
944         return Y.JSON.stringify(page);
945     },
947     /**
948      * Generate a drawable from the current in progress edit.
949      * @protected
950      * @method get_current_drawable
951      */
952     get_current_drawable: function() {
953         var comment,
954             annotation,
955             drawable = false;
957         if (!this.currentedit.start || !this.currentedit.end) {
958             return false;
959         }
961         if (this.currentedit.tool === 'comment') {
962             comment = new M.assignfeedback_editpdf.comment(this);
963             drawable = comment.draw_current_edit(this.currentedit);
964         } else {
965             annotation = this.create_annotation(this.currentedit.tool, {});
966             if (annotation) {
967                 drawable = annotation.draw_current_edit(this.currentedit);
968             }
969         }
971         return drawable;
972     },
974     /**
975      * Find an element within the dialogue.
976      * @protected
977      * @method get_dialogue_element
978      */
979     get_dialogue_element: function(selector) {
980         if (this.panel) {
981             return this.panel.one(selector);
982         } else {
983             return this.dialogue.get('boundingBox').one(selector);
984         }
985     },
987     /**
988      * Redraw the active edit.
989      * @protected
990      * @method redraw_active_edit
991      */
992     redraw_current_edit: function() {
993         if (this.currentdrawable) {
994             this.currentdrawable.erase();
995         }
996         this.currentdrawable = this.get_current_drawable();
997     },
999     /**
1000      * Event handler for mousedown or touchstart.
1001      * @protected
1002      * @param Event
1003      * @method edit_start
1004      */
1005     edit_start: function(e) {
1006         var canvas = this.get_dialogue_element(SELECTOR.DRAWINGCANVAS),
1007             offset = canvas.getXY(),
1008             scrolltop = canvas.get('docScrollY'),
1009             scrollleft = canvas.get('docScrollX'),
1010             point = {x: e.clientX - offset[0] + scrollleft,
1011                      y: e.clientY - offset[1] + scrolltop},
1012             selected = false;
1014         // Ignore right mouse click.
1015         if (e.button === 3) {
1016             return;
1017         }
1019         if (this.currentedit.starttime) {
1020             return;
1021         }
1023         if (this.editingcomment) {
1024             return;
1025         }
1027         this.currentedit.starttime = new Date().getTime();
1028         this.currentedit.start = point;
1029         this.currentedit.end = {x: point.x, y: point.y};
1031         if (this.currentedit.tool === 'select') {
1032             var x = this.currentedit.end.x,
1033                 y = this.currentedit.end.y,
1034                 annotations = this.pages[this.currentpage].annotations;
1035             // Find the first annotation whose bounds encompass the click.
1036             Y.each(annotations, function(annotation) {
1037                 if (((x - annotation.x) * (x - annotation.endx)) <= 0 &&
1038                     ((y - annotation.y) * (y - annotation.endy)) <= 0) {
1039                     selected = annotation;
1040                 }
1041             });
1043             if (selected) {
1044                 this.lastannotation = this.currentannotation;
1045                 this.currentannotation = selected;
1046                 if (this.lastannotation && this.lastannotation !== selected) {
1047                     // Redraw the last selected annotation to remove the highlight.
1048                     if (this.lastannotation.drawable) {
1049                         this.lastannotation.drawable.erase();
1050                         this.drawables.push(this.lastannotation.draw());
1051                     }
1052                 }
1053                 // Redraw the newly selected annotation to show the highlight.
1054                 if (this.currentannotation.drawable) {
1055                     this.currentannotation.drawable.erase();
1056                 }
1057                 this.drawables.push(this.currentannotation.draw());
1058             } else {
1059                 this.lastannotation = this.currentannotation;
1060                 this.currentannotation = null;
1062                 // Redraw the last selected annotation to remove the highlight.
1063                 if (this.lastannotation && this.lastannotation.drawable) {
1064                     this.lastannotation.drawable.erase();
1065                     this.drawables.push(this.lastannotation.draw());
1066                 }
1067             }
1068         }
1069         if (this.currentannotation) {
1070             // Used to calculate drag offset.
1071             this.currentedit.annotationstart = {x: this.currentannotation.x,
1072                                                  y: this.currentannotation.y};
1073         }
1074     },
1076     /**
1077      * Event handler for mousemove.
1078      * @protected
1079      * @param Event
1080      * @method edit_move
1081      */
1082     edit_move: function(e) {
1083         e.preventDefault();
1084         var bounds = this.get_canvas_bounds(),
1085             canvas = this.get_dialogue_element(SELECTOR.DRAWINGCANVAS),
1086             drawingregion = this.get_dialogue_element(SELECTOR.DRAWINGREGION),
1087             clientpoint = new M.assignfeedback_editpdf.point(e.clientX + canvas.get('docScrollX'),
1088                                                              e.clientY + canvas.get('docScrollY')),
1089             point = this.get_canvas_coordinates(clientpoint),
1090             diffX,
1091             diffY;
1093         // Ignore events out of the canvas area.
1094         if (point.x < 0 || point.x > bounds.width || point.y < 0 || point.y > bounds.height) {
1095             return;
1096         }
1098         if (this.currentedit.tool === 'pen') {
1099             this.currentedit.path.push(point);
1100         }
1102         if (this.currentedit.tool === 'select') {
1103             if (this.currentannotation && this.currentedit) {
1104                 this.currentannotation.move(this.currentedit.annotationstart.x + point.x - this.currentedit.start.x,
1105                                              this.currentedit.annotationstart.y + point.y - this.currentedit.start.y);
1106             }
1107         } else if (this.currentedit.tool === 'drag') {
1108             diffX = point.x - this.currentedit.start.x;
1109             diffY = point.y - this.currentedit.start.y;
1111             drawingregion.getDOMNode().scrollLeft -= diffX;
1112             drawingregion.getDOMNode().scrollTop -= diffY;
1114         } else {
1115             if (this.currentedit.start) {
1116                 this.currentedit.end = point;
1117                 this.redraw_current_edit();
1118             }
1119         }
1120     },
1122     /**
1123      * Event handler for mouseup or touchend.
1124      * @protected
1125      * @param Event
1126      * @method edit_end
1127      */
1128     edit_end: function() {
1129         var duration,
1130             comment,
1131             annotation;
1133         duration = new Date().getTime() - this.currentedit.start;
1135         if (duration < CLICKTIMEOUT || this.currentedit.start === false) {
1136             return;
1137         }
1139         if (this.currentedit.tool === 'comment') {
1140             if (this.currentdrawable) {
1141                 this.currentdrawable.erase();
1142             }
1143             this.currentdrawable = false;
1144             comment = new M.assignfeedback_editpdf.comment(this);
1145             if (comment.init_from_edit(this.currentedit)) {
1146                 this.pages[this.currentpage].comments.push(comment);
1147                 this.drawables.push(comment.draw(true));
1148                 this.editingcomment = true;
1149             }
1150         } else {
1151             annotation = this.create_annotation(this.currentedit.tool, {});
1152             if (annotation) {
1153                 if (this.currentdrawable) {
1154                     this.currentdrawable.erase();
1155                 }
1156                 this.currentdrawable = false;
1157                 if (annotation.init_from_edit(this.currentedit)) {
1158                     this.pages[this.currentpage].annotations.push(annotation);
1159                     this.drawables.push(annotation.draw());
1160                 }
1161             }
1162         }
1164         // Save the changes.
1165         this.save_current_page();
1167         // Reset the current edit.
1168         this.currentedit.starttime = 0;
1169         this.currentedit.start = false;
1170         this.currentedit.end = false;
1171         this.currentedit.path = [];
1172     },
1174     /**
1175      * Resize the dialogue window when the browser is resized.
1176      * @public
1177      * @method resize
1178      */
1179     resize: function() {
1180         var drawingregion, drawregionheight;
1182         if (this.dialogue) {
1183             if (!this.dialogue.get('visible')) {
1184                 return;
1185             }
1186             this.dialogue.centerDialogue();
1187         }
1189         // Make sure the dialogue box is not bigger than the max height of the viewport.
1190         drawregionheight = Y.one('body').get('winHeight') - 120; // Space for toolbar + titlebar.
1191         if (drawregionheight < 100) {
1192             drawregionheight = 100;
1193         }
1194         drawingregion = this.get_dialogue_element(SELECTOR.DRAWINGREGION);
1195         if (this.dialogue) {
1196             drawingregion.setStyle('maxHeight', drawregionheight + 'px');
1197         }
1198         this.redraw();
1199         return true;
1200     },
1202     /**
1203      * Factory method for creating annotations of the correct subclass.
1204      * @public
1205      * @method create_annotation
1206      */
1207     create_annotation: function(type, data) {
1208         data.type = type;
1209         data.editor = this;
1210         if (type === "line") {
1211             return new M.assignfeedback_editpdf.annotationline(data);
1212         } else if (type === "rectangle") {
1213             return new M.assignfeedback_editpdf.annotationrectangle(data);
1214         } else if (type === "oval") {
1215             return new M.assignfeedback_editpdf.annotationoval(data);
1216         } else if (type === "pen") {
1217             return new M.assignfeedback_editpdf.annotationpen(data);
1218         } else if (type === "highlight") {
1219             return new M.assignfeedback_editpdf.annotationhighlight(data);
1220         } else if (type === "stamp") {
1221             return new M.assignfeedback_editpdf.annotationstamp(data);
1222         }
1223         return false;
1224     },
1226     /**
1227      * Save all the annotations and comments for the current page.
1228      * @protected
1229      * @method save_current_page
1230      */
1231     save_current_page: function() {
1232         this.clear_warnings(false);
1233         if (this.get('destroyed')) {
1234             return;
1235         }
1236         var ajaxurl = AJAXBASE,
1237             config;
1239         config = {
1240             method: 'post',
1241             context: this,
1242             sync: false,
1243             data: {
1244                 'sesskey': M.cfg.sesskey,
1245                 'action': 'savepage',
1246                 'index': this.currentpage,
1247                 'userid': this.get('userid'),
1248                 'attemptnumber': this.get('attemptnumber'),
1249                 'assignmentid': this.get('assignmentid'),
1250                 'page': this.stringify_current_page()
1251             },
1252             on: {
1253                 success: function(tid, response) {
1254                     var jsondata;
1255                     try {
1256                         jsondata = Y.JSON.parse(response.responseText);
1257                         if (jsondata.error) {
1258                             return new M.core.ajaxException(jsondata);
1259                         }
1260                         // Show warning that we have not saved the feedback.
1261                         Y.one(SELECTOR.UNSAVEDCHANGESINPUT).set('value', 'true');
1262                         this.warning(M.util.get_string('draftchangessaved', 'assignfeedback_editpdf'), true);
1263                     } catch (e) {
1264                         return new M.core.exception(e);
1265                     }
1266                 },
1267                 failure: function(tid, response) {
1268                     return new M.core.exception(response.responseText);
1269                 }
1270             }
1271         };
1273         Y.io(ajaxurl, config);
1274     },
1276     /**
1277      * Event handler to open the comment search interface.
1278      *
1279      * @param Event e
1280      * @protected
1281      * @method open_search_comments
1282      */
1283     open_search_comments: function(e) {
1284         if (!this.searchcommentswindow) {
1285             this.searchcommentswindow = new M.assignfeedback_editpdf.commentsearch({
1286                 editor: this
1287             });
1288         }
1290         this.searchcommentswindow.show();
1291         e.preventDefault();
1292     },
1294     /**
1295      * Toggle function to expand/collapse all comments on page.
1296      *
1297      * @protected
1298      * @method expandCollapseComments
1299      */
1300     expandCollapseComments: function() {
1301         var comments = Y.all('.commentdrawable');
1303         if (this.collapsecomments) {
1304             this.collapsecomments = false;
1305             comments.removeClass('commentcollapsed');
1306         } else {
1307             this.collapsecomments = true;
1308             comments.addClass('commentcollapsed');
1309         }
1310     },
1312     /**
1313      * Redraw all the comments and annotations.
1314      * @protected
1315      * @method redraw
1316      */
1317     redraw: function() {
1318         var i,
1319             page;
1321         page = this.pages[this.currentpage];
1322         if (page === undefined) {
1323             return; // Can happen if a redraw is triggered by an event, before the page has been selected.
1324         }
1325         while (this.drawables.length > 0) {
1326             this.drawables.pop().erase();
1327         }
1329         for (i = 0; i < page.annotations.length; i++) {
1330             this.drawables.push(page.annotations[i].draw());
1331         }
1332         for (i = 0; i < page.comments.length; i++) {
1333             this.drawables.push(page.comments[i].draw(false));
1334         }
1335     },
1337     /**
1338      * Clear all current warning messages from display.
1339      * @protected
1340      * @method clear_warnings
1341      * @param {Boolean} allwarnings If true, all previous warnings are removed.
1342      */
1343     clear_warnings: function(allwarnings) {
1344         // Remove all warning messages, they may not relate to the current document or page anymore.
1345         var warningregion = this.get_dialogue_element(SELECTOR.WARNINGMESSAGECONTAINER);
1346         if (allwarnings) {
1347             warningregion.empty();
1348         } else {
1349             warningregion.all('.alert-info').remove(true);
1350         }
1351     },
1353     /**
1354      * Load the image for this pdf page and remove the loading icon (if there).
1355      * @protected
1356      * @method change_page
1357      */
1358     change_page: function() {
1359         var drawingcanvas = this.get_dialogue_element(SELECTOR.DRAWINGCANVAS),
1360             page,
1361             previousbutton,
1362             nextbutton;
1364         previousbutton = this.get_dialogue_element(SELECTOR.PREVIOUSBUTTON);
1365         nextbutton = this.get_dialogue_element(SELECTOR.NEXTBUTTON);
1367         if (this.currentpage > 0) {
1368             previousbutton.removeAttribute('disabled');
1369         } else {
1370             previousbutton.setAttribute('disabled', 'true');
1371         }
1372         if (this.currentpage < (this.pagecount - 1)) {
1373             nextbutton.removeAttribute('disabled');
1374         } else {
1375             nextbutton.setAttribute('disabled', 'true');
1376         }
1378         page = this.pages[this.currentpage];
1379         this.loadingicon.hide();
1380         drawingcanvas.setStyle('backgroundImage', 'url("' + page.url + '")');
1381         drawingcanvas.setStyle('width', page.width + 'px');
1382         drawingcanvas.setStyle('height', page.height + 'px');
1383         drawingcanvas.scrollIntoView();
1385         // Update page select.
1386         this.get_dialogue_element(SELECTOR.PAGESELECT).set('selectedIndex', this.currentpage);
1388         this.resize(); // Internally will call 'redraw', after checking the dialogue size.
1389     },
1391     /**
1392      * Now we know how many pages there are,
1393      * we can enable the navigation controls.
1394      * @protected
1395      * @method setup_navigation
1396      */
1397     setup_navigation: function() {
1398         var pageselect,
1399             i,
1400             strinfo,
1401             option,
1402             previousbutton,
1403             nextbutton;
1405         pageselect = this.get_dialogue_element(SELECTOR.PAGESELECT);
1407         var options = pageselect.all('option');
1408         if (options.size() <= 1) {
1409             for (i = 0; i < this.pages.length; i++) {
1410                 option = Y.Node.create('<option/>');
1411                 option.setAttribute('value', i);
1412                 strinfo = {page: i + 1, total: this.pages.length};
1413                 option.setHTML(M.util.get_string('pagexofy', 'assignfeedback_editpdf', strinfo));
1414                 pageselect.append(option);
1415             }
1416         }
1417         pageselect.removeAttribute('disabled');
1418         pageselect.on('change', function() {
1419             this.currentpage = pageselect.get('value');
1420             this.clear_warnings(false);
1421             this.change_page();
1422         }, this);
1424         previousbutton = this.get_dialogue_element(SELECTOR.PREVIOUSBUTTON);
1425         nextbutton = this.get_dialogue_element(SELECTOR.NEXTBUTTON);
1427         previousbutton.on('click', this.previous_page, this);
1428         previousbutton.on('key', this.previous_page, 'down:13', this);
1429         nextbutton.on('click', this.next_page, this);
1430         nextbutton.on('key', this.next_page, 'down:13', this);
1431     },
1433     /**
1434      * Navigate to the previous page.
1435      * @protected
1436      * @method previous_page
1437      */
1438     previous_page: function(e) {
1439         e.preventDefault();
1440         this.currentpage--;
1441         if (this.currentpage < 0) {
1442             this.currentpage = 0;
1443         }
1444         this.clear_warnings(false);
1445         this.change_page();
1446     },
1448     /**
1449      * Navigate to the next page.
1450      * @protected
1451      * @method next_page
1452      */
1453     next_page: function(e) {
1454         e.preventDefault();
1455         this.currentpage++;
1456         if (this.currentpage >= this.pages.length) {
1457             this.currentpage = this.pages.length - 1;
1458         }
1459         this.clear_warnings(false);
1460         this.change_page();
1461     },
1463     /**
1464      * Update any absolutely positioned nodes, within each drawable, when the drawing canvas is scrolled
1465      * @protected
1466      * @method move_canvas
1467      */
1468     move_canvas: function() {
1469         var drawingregion, x, y, i;
1471         drawingregion = this.get_dialogue_element(SELECTOR.DRAWINGREGION);
1472         x = parseInt(drawingregion.get('scrollLeft'), 10);
1473         y = parseInt(drawingregion.get('scrollTop'), 10);
1475         for (i = 0; i < this.drawables.length; i++) {
1476             this.drawables[i].scroll_update(x, y);
1477         }
1478     },
1480     /**
1481      * Test the browser support for options objects on event listeners.
1482      * @return Boolean
1483      */
1484     event_listener_options_supported: function() {
1485         var passivesupported = false,
1486             options,
1487             testeventname = "testpassiveeventoptions";
1489         // Options support testing example from:
1490         // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
1492         try {
1493             options = Object.defineProperty({}, "passive", {
1494                 get: function() {
1495                     passivesupported = true;
1496                 }
1497             });
1499             // We use an event name that is not likely to conflict with any real event.
1500             document.addEventListener(testeventname, options, options);
1501             // We remove the event listener as we have tested the options already.
1502             document.removeEventListener(testeventname, options, options);
1503         } catch(err) {
1504             // It's already false.
1505             passivesupported = false;
1506         }
1507         return passivesupported;
1508     },
1510     /**
1511      * Disable Touch Move scrolling
1512      */
1513     disable_touch_scroll: function() {
1514         if (this.event_listener_options_supported()) {
1515             document.addEventListener('touchmove', this.stop_touch_scroll, {passive: false});
1516         }
1517     },
1519     /**
1520      * Stop Touch Scrolling
1521      * @param {Object} e
1522      */
1523     stop_touch_scroll: function(e) {
1524         e.stopPropagation();
1525         e.preventDefault();
1526     }
1528 };
1530 Y.extend(EDITOR, Y.Base, EDITOR.prototype, {
1531     NAME: 'moodle-assignfeedback_editpdf-editor',
1532     ATTRS: {
1533         userid: {
1534             validator: Y.Lang.isInteger,
1535             value: 0
1536         },
1537         assignmentid: {
1538             validator: Y.Lang.isInteger,
1539             value: 0
1540         },
1541         attemptnumber: {
1542             validator: Y.Lang.isInteger,
1543             value: 0
1544         },
1545         header: {
1546             validator: Y.Lang.isString,
1547             value: ''
1548         },
1549         body: {
1550             validator: Y.Lang.isString,
1551             value: ''
1552         },
1553         footer: {
1554             validator: Y.Lang.isString,
1555             value: ''
1556         },
1557         linkid: {
1558             validator: Y.Lang.isString,
1559             value: ''
1560         },
1561         deletelinkid: {
1562             validator: Y.Lang.isString,
1563             value: ''
1564         },
1565         readonly: {
1566             validator: Y.Lang.isBoolean,
1567             value: true
1568         },
1569         stampfiles: {
1570             validator: Y.Lang.isArray,
1571             value: ''
1572         }
1573     }
1574 });
1576 M.assignfeedback_editpdf = M.assignfeedback_editpdf || {};
1577 M.assignfeedback_editpdf.editor = M.assignfeedback_editpdf.editor || {};
1579 /**
1580  * Init function - will create a new instance every time.
1581  * @method editor.init
1582  * @static
1583  * @param {Object} params
1584  */
1585 M.assignfeedback_editpdf.editor.init = M.assignfeedback_editpdf.editor.init || function(params) {
1586     if (typeof M.assignfeedback_editpdf.instance !== 'undefined') {
1587         M.assignfeedback_editpdf.instance.destroy();
1588     }
1590     M.assignfeedback_editpdf.instance = new EDITOR(params);
1591     return M.assignfeedback_editpdf.instance;
1592 };