Bug 18417: Advanced Editor (Rancor) add shortcuts for copyright symbols (C) (P)
[koha.git] / koha-tmpl / intranet-tmpl / lib / koha / cateditor / marc-editor.js
1 /**
2  * Copyright 2015 ByWater Solutions
3  *
4  * This file is part of Koha.
5  *
6  * Koha is free software; you can redistribute it and/or modify it
7  * under the terms of the GNU General Public License as published by
8  * the Free Software Foundation; either version 3 of the License, or
9  * (at your option) any later version.
10  *
11  * Koha is distributed in the hope that it will be useful, but
12  * WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14  * GNU General Public License for more details.
15  *
16  * You should have received a copy of the GNU General Public License
17  * along with Koha; if not, see <http://www.gnu.org/licenses>.
18  */
19
20 define( [ 'marc-record', 'koha-backend', 'preferences', 'text-marc', 'widget' ], function( MARC, KohaBackend, Preferences, TextMARC, Widget ) {
21     var NOTIFY_TIMEOUT = 250;
22
23     function editorCursorActivity( cm ) {
24         var editor = cm.marceditor;
25         var field = editor.getCurrentField();
26         if ( !field ) return;
27
28         // Set overwrite mode for tag numbers/indicators and contents of fixed fields
29         if ( field.isControlField || cm.getCursor().ch < 8 ) {
30             cm.toggleOverwrite(true);
31         } else {
32             cm.toggleOverwrite(false);
33         }
34
35         editor.onCursorActivity();
36     }
37
38     // This function exists to prevent inserting or partially deleting text that belongs to a
39     // widget. The 'marcAware' change source exists for other parts of the editor code to bypass
40     // this check.
41     function editorBeforeChange( cm, change ) {
42         var editor = cm.marceditor;
43         if ( editor.textMode || change.origin == 'marcAware' || change.origin == 'widget.clearToText' ) return;
44
45         // FIXME: Should only cancel changes if this is a control field/subfield widget
46         if ( change.from.line !== change.to.line || Math.abs( change.from.ch - change.to.ch ) > 1 || change.text.length != 1 || change.text[0].length != 0 ) return; // Not single-char change
47
48         if ( change.from.ch == change.to.ch - 1 && cm.findMarksAt( { line: change.from.line, ch: change.from.ch + 1 } ).length ) {
49             change.cancel();
50         } else if ( change.from.ch == change.to.ch && cm.findMarksAt(change.from).length && !change.text[0] == '‡' ) {
51             change.cancel();
52         }
53     }
54
55     function editorChanges( cm, changes ) {
56         var editor = cm.marceditor;
57         if ( editor.textMode ) return;
58
59         for (var i = 0; i < changes.length; i++) {
60             var change = changes[i];
61
62             var origin = change.from.line;
63             var newTo = CodeMirror.changeEnd(change);
64
65             for (var delLine = origin; delLine <= change.to.line; delLine++) {
66                 // Line deleted; currently nothing to do
67             }
68
69             for (var line = origin; line <= newTo.line; line++) {
70                 if ( Preferences.user.fieldWidgets ) Widget.UpdateLine( cm.marceditor, line );
71                 if ( change.origin != 'setValue' && change.origin != 'marcWidgetPrefill' && change.origin != 'widget.clearToText' ) {
72                     cm.addLineClass( line, 'wrapper', 'modified-line' );
73                     editor.modified = true;
74                 }
75             }
76         }
77
78         Widget.ActivateAt( cm, cm.getCursor() );
79         cm.marceditor.startNotify();
80     }
81
82     function editorSetOverwriteMode( cm, newState ) {
83         var editor = cm.marceditor;
84
85         editor.overwriteMode = newState;
86     }
87
88     // Editor helper functions
89     function activateTabPosition( cm, pos, idx ) {
90         // Allow tabbing to as-yet-nonexistent positions
91         var lenDiff = pos.ch - cm.getLine( pos.line ).length;
92         if ( lenDiff > 0 ) {
93             var extra = '';
94             while ( lenDiff-- > 0 ) extra += ' ';
95             if ( pos.prefill ) extra += pos.prefill;
96             cm.replaceRange( extra, { line: pos.line } );
97         }
98
99         cm.setCursor( pos );
100         Widget.ActivateAt( cm, pos, idx );
101     }
102
103     function getTabPositions( editor, cur ) {
104         cur = cur || editor.cm.getCursor();
105         var field = editor.getFieldAt( cur.line );
106
107         if ( field ) {
108             if ( field.isControlField ) {
109                 var positions = [ { ch: 0 }, { ch: 4 } ];
110
111                 $.each( positions, function( undef, pos ) {
112                     pos.line = cur.line;
113                 } );
114
115                 return positions;
116             } else {
117                 var positions = [ { ch: 0 }, { ch: 4, prefill: '_' }, { ch: 6, prefill: '_' } ];
118
119                 $.each( positions, function( undef, pos ) {
120                     pos.line = cur.line;
121                 } );
122                 $.each( field.getSubfields(), function( undef, subfield ) {
123                     positions.push( { line: cur.line, ch: subfield.contentsStart } );
124                 } );
125
126                 // Allow to tab to start of empty field
127                 if ( field.getSubfields().length == 0 ) {
128                     positions.push( { line: cur.line, ch: 8 } );
129                 }
130
131                 return positions;
132             }
133         } else {
134             return [];
135         }
136     }
137
138     var _editorKeys = {
139         'Alt-C': function( cm ) {
140             cm.replaceRange( '©', cm.getCursor() );
141         },
142
143         'Alt-P': function( cm ) {
144             cm.replaceRange( '℗', cm.getCursor() );
145         },
146
147         Enter: function( cm ) {
148             var cursor = cm.getCursor();
149             cm.replaceRange( '\n', { line: cursor.line }, null, 'marcAware' );
150             cm.setCursor( { line: cursor.line + 1, ch: 0 } );
151         },
152
153         'Shift-Enter': function( cm ) {
154             var cur = cm.getCursor();
155
156             cm.replaceRange( "\n", cur, null );
157         },
158
159         'Ctrl-X': function( cm ) {
160             // Delete line (or cut)
161             if ( cm.somethingSelected() ) return true;
162
163             cm.execCommand('deleteLine');
164         },
165
166         'Shift-Ctrl-X': function( cm ) {
167             // Delete subfield
168             var field = cm.marceditor.getCurrentField();
169             if ( !field ) return;
170
171             var subfield = field.getSubfieldAt( cm.getCursor().ch );
172             if ( subfield ) subfield.delete();
173         },
174
175         Tab: function( cm ) {
176             // Move through parts of tag/fixed fields
177             var positions = getTabPositions( cm.marceditor );
178             var cur = cm.getCursor();
179
180             for ( var i = 0; i < positions.length; i++ ) {
181                 if ( positions[i].ch > cur.ch ) {
182                     activateTabPosition( cm, positions[i] );
183                     return false;
184                 }
185             }
186
187             cm.setCursor( { line: cur.line + 1, ch: 0 } );
188         },
189
190         'Shift-Tab': function( cm ) {
191             // Move backwards through parts of tag/fixed fields
192             var positions = getTabPositions( cm.marceditor );
193             var cur = cm.getCursor();
194
195             for ( var i = positions.length - 1; i >= 0; i-- ) {
196                 if ( positions[i].ch < cur.ch ) {
197                     activateTabPosition( cm, positions[i], -1 );
198                     return false;
199                 }
200             }
201
202             if ( cur.line == 0 ) return;
203
204             var prevPositions = getTabPositions( cm.marceditor, { line: cur.line - 1, ch: cm.getLine( cur.line - 1 ).length } );
205
206             if ( prevPositions.length ) {
207                 activateTabPosition( cm, prevPositions[ prevPositions.length - 1 ], -1 );
208             } else {
209                 cm.setCursor( { line: cur.line - 1, ch: 0 } );
210             }
211         },
212
213         'Ctrl-D': function( cm ) {
214             // Insert subfield delimiter
215             var cur = cm.getCursor();
216
217             cm.replaceRange( "‡", cur, null );
218         },
219     };
220
221     // The objects below are part of a field/subfield manipulation API, accessed through the base
222     // editor object.
223     //
224     // Each one is tied to a particular line; this means that using a field or subfield object after
225     // any other changes to the record will cause entertaining explosions. The objects are meant to
226     // be temporary, and should only be reused with great care. The macro code does this only
227     // because it is careful to dispose of the object after any other updates.
228     //
229     // Note, however, tha you can continue to use a field object after changing subfields. It's just
230     // the subfield objects that become invalid.
231
232     // This is an exception raised by the EditorSubfield and EditorField when an invalid change is
233     // attempted.
234     function FieldError(line, message) {
235         this.line = line;
236         this.message = message;
237     };
238
239     FieldError.prototype.toString = function() {
240         return 'FieldError(' + this.line + ', "' + this.message + '")';
241     };
242
243     // This is the temporary object for a particular subfield in a field. Any change to any other
244     // subfields will invalidate this subfield object.
245     function EditorSubfield( field, index, start, end ) {
246         this.field = field;
247         this.index = index;
248         this.start = start;
249         this.end = end;
250
251         if ( this.field.isControlField ) {
252             this.contentsStart = start;
253             this.code = '@';
254         } else {
255             this.contentsStart = start + 2;
256             this.code =  this.field.contents.substr( this.start + 1, 1 );
257         }
258
259         this.cm = field.cm;
260
261         var marks = this.cm.findMarksAt( { line: field.line, ch: this.contentsStart } );
262         if ( marks[0] && marks[0].widget ) {
263             this.widget = marks[0].widget;
264
265             this.text = this.widget.text;
266             this.setText = this.widget.setText;
267             this.getFixed = this.widget.getFixed;
268             this.setFixed = this.widget.setFixed;
269         } else {
270             this.widget = null;
271             this.text = this.field.contents.substr( this.contentsStart, end - this.contentsStart );
272         }
273     };
274
275     $.extend( EditorSubfield.prototype, {
276         _invalid: function() {
277             return this.field._subfieldsInvalid();
278         },
279
280         delete: function() {
281             this.cm.replaceRange( "", { line: this.field.line, ch: this.start }, { line: this.field.line, ch: this.end }, 'marcAware' );
282         },
283         focus: function() {
284             this.cm.setCursor( { line: this.field.line, ch: this.contentsStart } );
285         },
286         focusEnd: function() {
287             this.cm.setCursor( { line: this.field.line, ch: this.end } );
288         },
289         getText: function() {
290             return this.text;
291         },
292         setText: function( text ) {
293             if ( !this._invalid() ) throw new FieldError( this.field.line, 'subfield invalid' );
294             this.cm.replaceRange( text, { line: this.field.line, ch: this.contentsStart }, { line: this.field.line, ch: this.end }, 'marcAware' );
295             this.field._invalidateSubfields();
296         },
297     } );
298
299     function EditorField( editor, line ) {
300         this.editor = editor;
301         this.line = line;
302
303         this.cm = editor.cm;
304
305         this._updateInfo();
306         this.tag = this.contents.substr( 0, 3 );
307         this.isControlField = ( this.tag < '010' );
308
309         if ( this.isControlField ) {
310             this._ind1 = this.contents.substr( 4, 1 );
311             this._ind2 = this.contents.substr( 6, 1 );
312         } else {
313             this._ind1 = null;
314             this._ind2 = null;
315         }
316
317         this.subfields = null;
318     }
319
320     $.extend( EditorField.prototype, {
321         _subfieldsInvalid: function() {
322             return !this.subfields;
323         },
324         _invalidateSubfields: function() {
325             this._subfields = null;
326         },
327
328         _updateInfo: function() {
329             this.info = this.editor.getLineInfo( { line: this.line, ch: 0 } );
330             if ( this.info == null ) throw new FieldError( 'Invalid field' );
331             this.contents = this.info.contents;
332         },
333         _scanSubfields: function() {
334             this._updateInfo();
335
336             if ( this.isControlField ) {
337                 this._subfields = [ new EditorSubfield( this, 0, 4, this.contents.length ) ];
338             } else {
339                 var field = this;
340                 var subfields = this.info.subfields;
341                 this._subfields = [];
342
343                 for (var i = 0; i < this.info.subfields.length; i++) {
344                     var end = i == subfields.length - 1 ? this.contents.length : subfields[i+1].ch;
345
346                     this._subfields.push( new EditorSubfield( this, i, subfields[i].ch, end ) );
347                 }
348             }
349         },
350
351         delete: function() {
352             this.cm.replaceRange( "", { line: this.line, ch: 0 }, { line: this.line + 1, ch: 0 }, 'marcAware' );
353         },
354         focus: function() {
355             this.cm.setCursor( { line: this.line, ch: 0 } );
356
357             return this;
358         },
359
360         getText: function() {
361             var result = '';
362
363             $.each( this.getSubfields(), function() {
364                 if ( this.code != '@' ) result += '‡' + this.code;
365
366                 result += this.getText();
367             } );
368
369             return result;
370         },
371         setText: function( text ) {
372             var indicator_match = /^([_ 0-9])([_ 0-9])\‡/.exec( text );
373             if ( indicator_match ) {
374                 text = text.substr(2);
375                 this.setIndicator1( indicator_match[1] );
376                 this.setIndicator2( indicator_match[2] );
377             }
378
379             this.cm.replaceRange( text, { line: this.line, ch: this.isControlField ? 4 : 8 }, { line: this.line }, 'marcAware' );
380             this._invalidateSubfields();
381
382             return this;
383         },
384
385         getIndicator1: function() {
386             return this._ind1;
387         },
388         getIndicator2: function() {
389             return this._ind2;
390         },
391         setIndicator1: function(val) {
392             if ( this.isControlField ) throw new FieldError('Cannot set indicators on control field');
393
394             this._ind1 = ( !val || val == ' ' ) ? '_' : val;
395             this.cm.replaceRange( this._ind1, { line: this.line, ch: 4 }, { line: this.line, ch: 5 }, 'marcAware' );
396
397             return this;
398         },
399         setIndicator2: function(val) {
400             if ( this.isControlField ) throw new FieldError('Cannot set indicators on control field');
401
402             this._ind2 = ( !val || val == ' ' ) ? '_' : val;
403             this.cm.replaceRange( this._ind2, { line: this.line, ch: 6 }, { line: this.line, ch: 7 }, 'marcAware' );
404
405             return this;
406         },
407
408         appendSubfield: function( code ) {
409             if ( this.isControlField ) throw new FieldError('Cannot add subfields to control field');
410
411             this._invalidateSubfields();
412             this.cm.replaceRange( '‡' + code, { line: this.line }, null, 'marcAware' );
413             var subfields = this.getSubfields();
414
415             return subfields[ subfields.length - 1 ];
416         },
417         insertSubfield: function( code, position ) {
418             if ( this.isControlField ) throw new FieldError('Cannot add subfields to control field');
419
420             position = position || 0;
421
422             var subfields = this.getSubfields();
423             this._invalidateSubfields();
424             this.cm.replaceRange( '‡' + code, { line: this.line, ch: subfields[position] ? subfields[position].start : null }, null, 'marcAware' );
425             subfields = this.getSubfields();
426
427             return subfields[ position ];
428         },
429         getSubfields: function( code ) {
430             if ( !this._subfields ) this._scanSubfields();
431             if ( code == null ) return this._subfields;
432
433             var result = [];
434
435             $.each( this._subfields, function() {
436                 if ( code == null || this.code == code ) result.push(this);
437             } );
438
439             return result;
440         },
441         getFirstSubfield: function( code ) {
442             var result = this.getSubfields( code );
443
444             return ( result && result.length ) ? result[0] : null;
445         },
446         getSubfieldAt: function( ch ) {
447             var subfields = this.getSubfields();
448
449             for (var i = 0; i < subfields.length; i++) {
450                 if ( subfields[i].start < ch && subfields[i].end >= ch ) return subfields[i];
451             }
452         },
453     } );
454
455     function MARCEditor( options ) {
456         this.cm = CodeMirror(
457             options.position,
458             {
459                 extraKeys: _editorKeys,
460                 gutters: [
461                     'modified-line-gutter',
462                 ],
463                 lineWrapping: true,
464                 mode: {
465                     name: 'marc',
466                     nonRepeatableTags: KohaBackend.GetTagsBy( '', 'repeatable', '0' ),
467                     nonRepeatableSubfields: KohaBackend.GetSubfieldsBy( '', 'repeatable', '0' )
468                 }
469             }
470         );
471         this.cm.marceditor = this;
472
473         this.cm.on( 'beforeChange', editorBeforeChange );
474         this.cm.on( 'changes', editorChanges );
475         this.cm.on( 'cursorActivity', editorCursorActivity );
476         this.cm.on( 'overwriteToggle', editorSetOverwriteMode );
477
478         this.onCursorActivity = options.onCursorActivity;
479
480         this.subscribers = [];
481         this.subscribe( function( marceditor ) {
482             Widget.Notify( marceditor );
483         } );
484     }
485
486     MARCEditor.FieldError = FieldError;
487
488     $.extend( MARCEditor.prototype, {
489         setUseWidgets: function( val ) {
490             if ( val ) {
491                 for ( var line = 0; line <= this.cm.lastLine(); line++ ) {
492                     Widget.UpdateLine( this, line );
493                 }
494             } else {
495                 $.each( this.cm.getAllMarks(), function( undef, mark ) {
496                     if ( mark.widget ) mark.widget.clearToText();
497                 } );
498             }
499         },
500
501         focus: function() {
502             this.cm.focus();
503         },
504
505         getCursor: function() {
506             return this.cm.getCursor();
507         },
508
509         refresh: function() {
510             this.cm.refresh();
511         },
512
513         displayRecord: function( record ) {
514             this.cm.setValue( TextMARC.RecordToText(record) );
515             this.modified = false;
516         },
517
518         getRecord: function() {
519             this.textMode = true;
520
521             $.each( this.cm.getAllMarks(), function( undef, mark ) {
522                 if ( mark.widget ) mark.widget.clearToText();
523             } );
524             var record = TextMARC.TextToRecord( this.cm.getValue() );
525             for ( var line = 0; line <= this.cm.lastLine(); line++ ) {
526                 if ( Preferences.user.fieldWidgets ) Widget.UpdateLine( this, line );
527             }
528
529             this.textMode = false;
530
531             return record;
532         },
533
534         getLineInfo: function( pos ) {
535             var contents = this.cm.getLine( pos.line );
536             if ( contents == null ) return {};
537
538             var tagNumber = contents.match( /^([A-Za-z0-9]{3})/ );
539
540             if ( !tagNumber ) return null; // No tag at all on this line
541             tagNumber = tagNumber[1];
542
543             if ( tagNumber < '010' ) return { tagNumber: tagNumber, contents: contents }; // No current subfield
544
545             var matcher = /‡([a-z0-9%])/g;
546             var match;
547
548             var subfields = [];
549             var currentSubfield;
550
551             while ( ( match = matcher.exec(contents) ) ) {
552                 subfields.push( { code: match[1], ch: match.index } );
553                 if ( match.index < pos.ch ) currentSubfield = match[1];
554             }
555
556             return { tagNumber: tagNumber, subfields: subfields, currentSubfield: currentSubfield, contents: contents };
557         },
558
559         addError: function( line, error ) {
560             var found = false;
561             var options = {};
562
563             if ( line == null ) {
564                 line = 0;
565                 options.above = true;
566             }
567
568             $.each( this.cm.getLineHandle(line).widgets || [], function( undef, widget ) {
569                 if ( !widget.isErrorMarker ) return;
570
571                 found = true;
572
573                 $( widget.node ).append( '; ' + error );
574                 widget.changed();
575
576                 return false;
577             } );
578
579             if ( found ) return;
580
581             var node = $( '<div class="structure-error"><i class="fa fa-remove"></i> ' + error + '</div>' )[0];
582             var widget = this.cm.addLineWidget( line, node, options );
583
584             widget.node = node;
585             widget.isErrorMarker = true;
586         },
587
588         removeErrors: function() {
589             for ( var line = 0; line < this.cm.lineCount(); line++ ) {
590                 $.each( this.cm.getLineHandle( line ).widgets || [], function( undef, lineWidget ) {
591                     if ( lineWidget.isErrorMarker ) lineWidget.clear();
592                 } );
593             }
594         },
595
596         startNotify: function() {
597             if ( this.notifyTimeout ) clearTimeout( this.notifyTimeout );
598             this.notifyTimeout = setTimeout( $.proxy( function() {
599                 this.notifyAll();
600
601                 this.notifyTimeout = null;
602             }, this ), NOTIFY_TIMEOUT );
603         },
604
605         notifyAll: function() {
606             $.each( this.subscribers, $.proxy( function( undef, subscriber ) {
607                 subscriber(this);
608             }, this ) );
609         },
610
611         subscribe: function( subscriber ) {
612             this.subscribers.push( subscriber );
613         },
614
615         createField: function( tag, line ) {
616             var contents = tag + ( tag < '010' ? ' ' : ' _ _ ' );
617
618             if ( line > this.cm.lastLine() ) {
619                 contents = '\n' + contents;
620             } else {
621                 contents = contents + '\n';
622             }
623
624             this.cm.replaceRange( contents, { line: line, ch: 0 }, null, 'marcAware' );
625
626             return new EditorField( this, line );
627         },
628
629         createFieldOrdered: function( tag ) {
630             var line, contents;
631
632             for ( line = 0; (contents = this.cm.getLine(line)); line++ ) {
633                 if ( contents && contents.substr(0, 3) > tag ) break;
634             }
635
636             return this.createField( tag, line );
637         },
638
639         createFieldGrouped: function( tag ) {
640             // Control fields should be inserted in actual order, whereas other fields should be
641             // inserted grouped
642             if ( tag < '010' ) return this.createFieldOrdered( tag );
643
644             var line, contents;
645
646             for ( line = 0; (contents = this.cm.getLine(line)); line++ ) {
647                 if ( contents && contents[0] > tag[0] ) break;
648             }
649
650             return this.createField( tag, line );
651         },
652
653         getFieldAt: function( line ) {
654             try {
655                 return new EditorField( this, line );
656             } catch (e) {
657                 return null;
658             }
659         },
660
661         getCurrentField: function() {
662             return this.getFieldAt( this.cm.getCursor().line );
663         },
664
665         getFields: function( tag ) {
666             var result = [];
667
668             if ( tag != null ) tag += ' ';
669
670             for ( var line = 0; line < this.cm.lineCount(); line++ ) {
671                 if ( tag && this.cm.getLine(line).substr( 0, 4 ) != tag ) continue;
672
673                 // If this throws a FieldError, pretend it doesn't exist
674                 try {
675                     result.push( new EditorField( this, line ) );
676                 } catch (e) {
677                     if ( !( e instanceof FieldError ) ) throw e;
678                 }
679             }
680
681             return result;
682         },
683
684         getFirstField: function( tag ) {
685             var result = this.getFields( tag );
686
687             return ( result && result.length ) ? result[0] : null;
688         },
689
690         getAllFields: function( tag ) {
691             return this.getFields( null );
692         },
693     } );
694
695     return MARCEditor;
696 } );