Bug 11559: (followup) fix first-character deletion, small usability issues
[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         Enter: function( cm ) {
140             var cursor = cm.getCursor();
141             cm.replaceRange( '\n', { line: cursor.line }, null, 'marcAware' );
142             cm.setCursor( { line: cursor.line + 1, ch: 0 } );
143         },
144
145         'Shift-Enter': function( cm ) {
146             var cur = cm.getCursor();
147
148             cm.replaceRange( "\n", cur, null );
149         },
150
151         'Ctrl-X': function( cm ) {
152             // Delete line (or cut)
153             if ( cm.somethingSelected() ) return true;
154
155             cm.execCommand('deleteLine');
156         },
157
158         'Shift-Ctrl-X': function( cm ) {
159             // Delete subfield
160             var field = cm.marceditor.getCurrentField();
161             if ( !field ) return;
162
163             var subfield = field.getSubfieldAt( cm.getCursor().ch );
164             if ( subfield ) subfield.delete();
165         },
166
167         Tab: function( cm ) {
168             // Move through parts of tag/fixed fields
169             var positions = getTabPositions( cm.marceditor );
170             var cur = cm.getCursor();
171
172             for ( var i = 0; i < positions.length; i++ ) {
173                 if ( positions[i].ch > cur.ch ) {
174                     activateTabPosition( cm, positions[i] );
175                     return false;
176                 }
177             }
178
179             cm.setCursor( { line: cur.line + 1, ch: 0 } );
180         },
181
182         'Shift-Tab': function( cm ) {
183             // Move backwards through parts of tag/fixed fields
184             var positions = getTabPositions( cm.marceditor );
185             var cur = cm.getCursor();
186
187             for ( var i = positions.length - 1; i >= 0; i-- ) {
188                 if ( positions[i].ch < cur.ch ) {
189                     activateTabPosition( cm, positions[i], -1 );
190                     return false;
191                 }
192             }
193
194             if ( cur.line == 0 ) return;
195
196             var prevPositions = getTabPositions( cm.marceditor, { line: cur.line - 1, ch: cm.getLine( cur.line - 1 ).length } );
197
198             if ( prevPositions.length ) {
199                 activateTabPosition( cm, prevPositions[ prevPositions.length - 1 ], -1 );
200             } else {
201                 cm.setCursor( { line: cur.line - 1, ch: 0 } );
202             }
203         },
204
205         'Ctrl-D': function( cm ) {
206             // Insert subfield delimiter
207             // This will be extended later to allow either a configurable subfield delimiter or just
208             // make it be the double cross.
209             var cur = cm.getCursor();
210
211             cm.replaceRange( "‡", cur, null );
212         },
213     };
214
215     // The objects below are part of a field/subfield manipulation API, accessed through the base
216     // editor object.
217     //
218     // Each one is tied to a particular line; this means that using a field or subfield object after
219     // any other changes to the record will cause entertaining explosions. The objects are meant to
220     // be temporary, and should only be reused with great care. The macro code does this only
221     // because it is careful to dispose of the object after any other updates.
222     //
223     // Note, however, tha you can continue to use a field object after changing subfields. It's just
224     // the subfield objects that become invalid.
225
226     // This is an exception raised by the EditorSubfield and EditorField when an invalid change is
227     // attempted.
228     function FieldError(line, message) {
229         this.line = line;
230         this.message = message;
231     };
232
233     FieldError.prototype.toString = function() {
234         return 'FieldError(' + this.line + ', "' + this.message + '")';
235     };
236
237     // This is the temporary object for a particular subfield in a field. Any change to any other
238     // subfields will invalidate this subfield object.
239     function EditorSubfield( field, index, start, end ) {
240         this.field = field;
241         this.index = index;
242         this.start = start;
243         this.end = end;
244
245         if ( this.field.isControlField ) {
246             this.contentsStart = start;
247             this.code = '@';
248         } else {
249             this.contentsStart = start + 2;
250             this.code =  this.field.contents.substr( this.start + 1, 1 );
251         }
252
253         this.cm = field.cm;
254
255         var marks = this.cm.findMarksAt( { line: field.line, ch: this.contentsStart } );
256         if ( marks[0] && marks[0].widget ) {
257             this.widget = marks[0].widget;
258
259             this.text = this.widget.text;
260             this.setText = this.widget.setText;
261             this.getFixed = this.widget.getFixed;
262             this.setFixed = this.widget.setFixed;
263         } else {
264             this.widget = null;
265             this.text = this.field.contents.substr( this.contentsStart, end - this.contentsStart );
266         }
267     };
268
269     $.extend( EditorSubfield.prototype, {
270         _invalid: function() {
271             return this.field._subfieldsInvalid();
272         },
273
274         delete: function() {
275             this.cm.replaceRange( "", { line: this.field.line, ch: this.start }, { line: this.field.line, ch: this.end }, 'marcAware' );
276         },
277         focus: function() {
278             this.cm.setCursor( { line: this.field.line, ch: this.contentsStart } );
279         },
280         focusEnd: function() {
281             this.cm.setCursor( { line: this.field.line, ch: this.end } );
282         },
283         getText: function() {
284             return this.text;
285         },
286         setText: function( text ) {
287             if ( !this._invalid() ) throw new FieldError( this.field.line, 'subfield invalid' );
288             this.cm.replaceRange( text, { line: this.field.line, ch: this.contentsStart }, { line: this.field.line, ch: this.end }, 'marcAware' );
289             this.field._invalidateSubfields();
290         },
291     } );
292
293     function EditorField( editor, line ) {
294         this.editor = editor;
295         this.line = line;
296
297         this.cm = editor.cm;
298
299         this._updateInfo();
300         this.tag = this.contents.substr( 0, 3 );
301         this.isControlField = ( this.tag < '010' );
302
303         if ( this.isControlField ) {
304             this._ind1 = this.contents.substr( 4, 1 );
305             this._ind2 = this.contents.substr( 6, 1 );
306         } else {
307             this._ind1 = null;
308             this._ind2 = null;
309         }
310
311         this.subfields = null;
312     }
313
314     $.extend( EditorField.prototype, {
315         _subfieldsInvalid: function() {
316             return !this.subfields;
317         },
318         _invalidateSubfields: function() {
319             this._subfields = null;
320         },
321
322         _updateInfo: function() {
323             this.info = this.editor.getLineInfo( { line: this.line, ch: 0 } );
324             if ( this.info == null ) throw new FieldError( 'Invalid field' );
325             this.contents = this.info.contents;
326         },
327         _scanSubfields: function() {
328             this._updateInfo();
329
330             if ( this.isControlField ) {
331                 this._subfields = [ new EditorSubfield( this, 0, 4, this.contents.length ) ];
332             } else {
333                 var field = this;
334                 var subfields = this.info.subfields;
335                 this._subfields = [];
336
337                 for (var i = 0; i < this.info.subfields.length; i++) {
338                     var end = i == subfields.length - 1 ? this.contents.length : subfields[i+1].ch;
339
340                     this._subfields.push( new EditorSubfield( this, i, subfields[i].ch, end ) );
341                 }
342             }
343         },
344
345         delete: function() {
346             this.cm.replaceRange( "", { line: this.line, ch: 0 }, { line: this.line + 1, ch: 0 }, 'marcAware' );
347         },
348         focus: function() {
349             this.cm.setCursor( { line: this.line, ch: 0 } );
350
351             return this;
352         },
353
354         getText: function() {
355             var result = '';
356
357             $.each( this.getSubfields(), function() {
358                 if ( this.code != '@' ) result += '‡' + this.code;
359
360                 result += this.getText();
361             } );
362
363             return result;
364         },
365         setText: function( text ) {
366             var indicator_match = /^([_ 0-9])([_ 0-9])\‡/.exec( text );
367             if ( indicator_match ) {
368                 text = text.substr(2);
369                 this.setIndicator1( indicator_match[1] );
370                 this.setIndicator2( indicator_match[2] );
371             }
372
373             this.cm.replaceRange( text, { line: this.line, ch: this.isControlField ? 4 : 8 }, { line: this.line }, 'marcAware' );
374             this._invalidateSubfields();
375
376             return this;
377         },
378
379         getIndicator1: function() {
380             return this._ind1;
381         },
382         getIndicator2: function() {
383             return this._ind2;
384         },
385         setIndicator1: function(val) {
386             if ( this.isControlField ) throw new FieldError('Cannot set indicators on control field');
387
388             this._ind1 = ( !val || val == ' ' ) ? '_' : val;
389             this.cm.replaceRange( this._ind1, { line: this.line, ch: 4 }, { line: this.line, ch: 5 }, 'marcAware' );
390
391             return this;
392         },
393         setIndicator2: function(val) {
394             if ( this.isControlField ) throw new FieldError('Cannot set indicators on control field');
395
396             this._ind2 = ( !val || val == ' ' ) ? '_' : val;
397             this.cm.replaceRange( this._ind2, { line: this.line, ch: 6 }, { line: this.line, ch: 7 }, 'marcAware' );
398
399             return this;
400         },
401
402         appendSubfield: function( code ) {
403             if ( this.isControlField ) throw new FieldError('Cannot add subfields to control field');
404
405             this._invalidateSubfields();
406             this.cm.replaceRange( '‡' + code, { line: this.line }, null, 'marcAware' );
407             var subfields = this.getSubfields();
408
409             return subfields[ subfields.length - 1 ];
410         },
411         insertSubfield: function( code, position ) {
412             if ( this.isControlField ) throw new FieldError('Cannot add subfields to control field');
413
414             position = position || 0;
415
416             var subfields = this.getSubfields();
417             this._invalidateSubfields();
418             this.cm.replaceRange( '‡' + code, { line: this.line, ch: subfields[position] ? subfields[position].start : null }, null, 'marcAware' );
419             subfields = this.getSubfields();
420
421             return subfields[ position ];
422         },
423         getSubfields: function( code ) {
424             if ( !this._subfields ) this._scanSubfields();
425             if ( code == null ) return this._subfields;
426
427             var result = [];
428
429             $.each( this._subfields, function() {
430                 if ( code == null || this.code == code ) result.push(this);
431             } );
432
433             return result;
434         },
435         getFirstSubfield: function( code ) {
436             var result = this.getSubfields( code );
437
438             return ( result && result.length ) ? result[0] : null;
439         },
440         getSubfieldAt: function( ch ) {
441             var subfields = this.getSubfields();
442
443             for (var i = 0; i < subfields.length; i++) {
444                 if ( subfields[i].start < ch && subfields[i].end >= ch ) return subfields[i];
445             }
446         },
447     } );
448
449     function MARCEditor( options ) {
450         this.cm = CodeMirror(
451             options.position,
452             {
453                 extraKeys: _editorKeys,
454                 gutters: [
455                     'modified-line-gutter',
456                 ],
457                 lineWrapping: true,
458                 mode: {
459                     name: 'marc',
460                     nonRepeatableTags: KohaBackend.GetTagsBy( '', 'repeatable', '0' ),
461                     nonRepeatableSubfields: KohaBackend.GetSubfieldsBy( '', 'repeatable', '0' )
462                 }
463             }
464         );
465         this.cm.marceditor = this;
466
467         this.cm.on( 'beforeChange', editorBeforeChange );
468         this.cm.on( 'changes', editorChanges );
469         this.cm.on( 'cursorActivity', editorCursorActivity );
470         this.cm.on( 'overwriteToggle', editorSetOverwriteMode );
471
472         this.onCursorActivity = options.onCursorActivity;
473
474         this.subscribers = [];
475         this.subscribe( function( marceditor ) {
476             Widget.Notify( marceditor );
477         } );
478     }
479
480     MARCEditor.FieldError = FieldError;
481
482     $.extend( MARCEditor.prototype, {
483         setUseWidgets: function( val ) {
484             if ( val ) {
485                 for ( var line = 0; line <= this.cm.lastLine(); line++ ) {
486                     Widget.UpdateLine( this, line );
487                 }
488             } else {
489                 $.each( this.cm.getAllMarks(), function( undef, mark ) {
490                     if ( mark.widget ) mark.widget.clearToText();
491                 } );
492             }
493         },
494
495         focus: function() {
496             this.cm.focus();
497         },
498
499         getCursor: function() {
500             return this.cm.getCursor();
501         },
502
503         refresh: function() {
504             this.cm.refresh();
505         },
506
507         displayRecord: function( record ) {
508             this.cm.setValue( TextMARC.RecordToText(record) );
509             this.modified = false;
510         },
511
512         getRecord: function() {
513             this.textMode = true;
514
515             $.each( this.cm.getAllMarks(), function( undef, mark ) {
516                 if ( mark.widget ) mark.widget.clearToText();
517             } );
518             var record = TextMARC.TextToRecord( this.cm.getValue() );
519             for ( var line = 0; line <= this.cm.lastLine(); line++ ) {
520                 if ( Preferences.user.fieldWidgets ) Widget.UpdateLine( this, line );
521             }
522
523             this.textMode = false;
524
525             return record;
526         },
527
528         getLineInfo: function( pos ) {
529             var contents = this.cm.getLine( pos.line );
530             if ( contents == null ) return {};
531
532             var tagNumber = contents.match( /^([A-Za-z0-9]{3})/ );
533
534             if ( !tagNumber ) return null; // No tag at all on this line
535             tagNumber = tagNumber[1];
536
537             if ( tagNumber < '010' ) return { tagNumber: tagNumber, contents: contents }; // No current subfield
538
539             var matcher = /‡([a-z0-9%])/g;
540             var match;
541
542             var subfields = [];
543             var currentSubfield;
544
545             while ( ( match = matcher.exec(contents) ) ) {
546                 subfields.push( { code: match[1], ch: match.index } );
547                 if ( match.index < pos.ch ) currentSubfield = match[1];
548             }
549
550             return { tagNumber: tagNumber, subfields: subfields, currentSubfield: currentSubfield, contents: contents };
551         },
552
553         addError: function( line, error ) {
554             var found = false;
555             var options = {};
556
557             if ( line == null ) {
558                 line = 0;
559                 options.above = true;
560             }
561
562             $.each( this.cm.getLineHandle(line).widgets || [], function( undef, widget ) {
563                 if ( !widget.isErrorMarker ) return;
564
565                 found = true;
566
567                 $( widget.node ).append( '; ' + error );
568                 widget.changed();
569
570                 return false;
571             } );
572
573             if ( found ) return;
574
575             var node = $( '<div class="structure-error"><i class="icon-remove"></i> ' + error + '</div>' )[0];
576             var widget = this.cm.addLineWidget( line, node, options );
577
578             widget.node = node;
579             widget.isErrorMarker = true;
580         },
581
582         removeErrors: function() {
583             for ( var line = 0; line < this.cm.lineCount(); line++ ) {
584                 $.each( this.cm.getLineHandle( line ).widgets || [], function( undef, lineWidget ) {
585                     if ( lineWidget.isErrorMarker ) lineWidget.clear();
586                 } );
587             }
588         },
589
590         startNotify: function() {
591             if ( this.notifyTimeout ) clearTimeout( this.notifyTimeout );
592             this.notifyTimeout = setTimeout( $.proxy( function() {
593                 this.notifyAll();
594
595                 this.notifyTimeout = null;
596             }, this ), NOTIFY_TIMEOUT );
597         },
598
599         notifyAll: function() {
600             $.each( this.subscribers, $.proxy( function( undef, subscriber ) {
601                 subscriber(this);
602             }, this ) );
603         },
604
605         subscribe: function( subscriber ) {
606             this.subscribers.push( subscriber );
607         },
608
609         createField: function( tag, line ) {
610             var contents = tag + ( tag < '010' ? ' ' : ' _ _ ' );
611
612             if ( line > this.cm.lastLine() ) {
613                 contents = '\n' + contents;
614             } else {
615                 contents = contents + '\n';
616             }
617
618             this.cm.replaceRange( contents, { line: line, ch: 0 }, null, 'marcAware' );
619
620             return new EditorField( this, line );
621         },
622
623         createFieldOrdered: function( tag ) {
624             var line, contents;
625
626             for ( line = 0; (contents = this.cm.getLine(line)); line++ ) {
627                 if ( contents && contents.substr(0, 3) > tag ) break;
628             }
629
630             return this.createField( tag, line );
631         },
632
633         createFieldGrouped: function( tag ) {
634             // Control fields should be inserted in actual order, whereas other fields should be
635             // inserted grouped
636             if ( tag < '010' ) return this.createFieldOrdered( tag );
637
638             var line, contents;
639
640             for ( line = 0; (contents = this.cm.getLine(line)); line++ ) {
641                 if ( contents && contents[0] > tag[0] ) break;
642             }
643
644             return this.createField( tag, line );
645         },
646
647         getFieldAt: function( line ) {
648             try {
649                 return new EditorField( this, line );
650             } catch (e) {
651                 return null;
652             }
653         },
654
655         getCurrentField: function() {
656             return this.getFieldAt( this.cm.getCursor().line );
657         },
658
659         getFields: function( tag ) {
660             var result = [];
661
662             if ( tag != null ) tag += ' ';
663
664             for ( var line = 0; line < this.cm.lineCount(); line++ ) {
665                 if ( tag && this.cm.getLine(line).substr( 0, 4 ) != tag ) continue;
666
667                 // If this throws a FieldError, pretend it doesn't exist
668                 try {
669                     result.push( new EditorField( this, line ) );
670                 } catch (e) {
671                     if ( !( e instanceof FieldError ) ) throw e;
672                 }
673             }
674
675             return result;
676         },
677
678         getFirstField: function( tag ) {
679             var result = this.getFields( tag );
680
681             return ( result && result.length ) ? result[0] : null;
682         },
683
684         getAllFields: function( tag ) {
685             return this.getFields( null );
686         },
687     } );
688
689     return MARCEditor;
690 } );