2 * Copyright 2015 ByWater Solutions
4 * This file is part of Koha.
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.
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.
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>.
20 define( [ 'marc-record', 'koha-backend', 'preferences', 'text-marc', 'widget' ], function( MARC, KohaBackend, Preferences, TextMARC, Widget ) {
21 var NOTIFY_TIMEOUT = 250;
23 function editorCursorActivity( cm ) {
24 var editor = cm.marceditor;
25 var field = editor.getCurrentField();
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);
32 cm.toggleOverwrite(false);
35 editor.onCursorActivity();
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
41 function editorBeforeChange( cm, change ) {
42 var editor = cm.marceditor;
43 if ( editor.textMode || change.origin == 'marcAware' || change.origin == 'widget.clearToText' ) return;
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
48 if ( change.from.ch == change.to.ch - 1 && cm.findMarksAt( { line: change.from.line, ch: change.from.ch + 1 } ).length ) {
50 } else if ( change.from.ch == change.to.ch && cm.findMarksAt(change.from).length && !change.text[0] == '‡' ) {
55 function editorChanges( cm, changes ) {
56 var editor = cm.marceditor;
57 if ( editor.textMode ) return;
59 for (var i = 0; i < changes.length; i++) {
60 var change = changes[i];
62 var origin = change.from.line;
63 var newTo = CodeMirror.changeEnd(change);
65 for (var delLine = origin; delLine <= change.to.line; delLine++) {
66 // Line deleted; currently nothing to do
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;
78 Widget.ActivateAt( cm, cm.getCursor() );
79 cm.marceditor.startNotify();
82 function editorSetOverwriteMode( cm, newState ) {
83 var editor = cm.marceditor;
85 editor.overwriteMode = newState;
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;
94 while ( lenDiff-- > 0 ) extra += ' ';
95 if ( pos.prefill ) extra += pos.prefill;
96 cm.replaceRange( extra, { line: pos.line } );
100 Widget.ActivateAt( cm, pos, idx );
103 function getTabPositions( editor, cur ) {
104 cur = cur || editor.cm.getCursor();
105 var field = editor.getFieldAt( cur.line );
108 if ( field.isControlField ) {
109 var positions = [ { ch: 0 }, { ch: 4 } ];
111 $.each( positions, function( undef, pos ) {
117 var positions = [ { ch: 0 }, { ch: 4, prefill: '_' }, { ch: 6, prefill: '_' } ];
119 $.each( positions, function( undef, pos ) {
122 $.each( field.getSubfields(), function( undef, subfield ) {
123 positions.push( { line: cur.line, ch: subfield.contentsStart } );
126 // Allow to tab to start of empty field
127 if ( field.getSubfields().length == 0 ) {
128 positions.push( { line: cur.line, ch: 8 } );
139 'Alt-C': function( cm ) {
140 cm.replaceRange( '©', cm.getCursor() );
143 'Alt-P': function( cm ) {
144 cm.replaceRange( '℗', cm.getCursor() );
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 } );
153 'Shift-Enter': function( cm ) {
154 var cur = cm.getCursor();
156 cm.replaceRange( "\n", cur, null );
159 'Ctrl-X': function( cm ) {
160 // Delete line (or cut)
161 if ( cm.somethingSelected() ) return true;
163 cm.execCommand('deleteLine');
166 'Shift-Ctrl-X': function( cm ) {
168 var field = cm.marceditor.getCurrentField();
169 if ( !field ) return;
171 var subfield = field.getSubfieldAt( cm.getCursor().ch );
172 if ( subfield ) subfield.delete();
175 Tab: function( cm ) {
176 // Move through parts of tag/fixed fields
177 var positions = getTabPositions( cm.marceditor );
178 var cur = cm.getCursor();
180 for ( var i = 0; i < positions.length; i++ ) {
181 if ( positions[i].ch > cur.ch ) {
182 activateTabPosition( cm, positions[i] );
187 cm.setCursor( { line: cur.line + 1, ch: 0 } );
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();
195 for ( var i = positions.length - 1; i >= 0; i-- ) {
196 if ( positions[i].ch < cur.ch ) {
197 activateTabPosition( cm, positions[i], -1 );
202 if ( cur.line == 0 ) return;
204 var prevPositions = getTabPositions( cm.marceditor, { line: cur.line - 1, ch: cm.getLine( cur.line - 1 ).length } );
206 if ( prevPositions.length ) {
207 activateTabPosition( cm, prevPositions[ prevPositions.length - 1 ], -1 );
209 cm.setCursor( { line: cur.line - 1, ch: 0 } );
213 'Ctrl-D': function( cm ) {
214 // Insert subfield delimiter
215 var cur = cm.getCursor();
217 cm.replaceRange( "‡", cur, null );
221 // The objects below are part of a field/subfield manipulation API, accessed through the base
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.
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.
232 // This is an exception raised by the EditorSubfield and EditorField when an invalid change is
234 function FieldError(line, message) {
236 this.message = message;
239 FieldError.prototype.toString = function() {
240 return 'FieldError(' + this.line + ', "' + this.message + '")';
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 ) {
251 if ( this.field.isControlField ) {
252 this.contentsStart = start;
255 this.contentsStart = start + 2;
256 this.code = this.field.contents.substr( this.start + 1, 1 );
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;
265 this.text = this.widget.text;
266 this.setText = this.widget.setText;
267 this.getFixed = this.widget.getFixed;
268 this.setFixed = this.widget.setFixed;
271 this.text = this.field.contents.substr( this.contentsStart, end - this.contentsStart );
275 $.extend( EditorSubfield.prototype, {
276 _invalid: function() {
277 return this.field._subfieldsInvalid();
281 this.cm.replaceRange( "", { line: this.field.line, ch: this.start }, { line: this.field.line, ch: this.end }, 'marcAware' );
284 this.cm.setCursor( { line: this.field.line, ch: this.contentsStart } );
286 focusEnd: function() {
287 this.cm.setCursor( { line: this.field.line, ch: this.end } );
289 getText: function() {
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();
299 function EditorField( editor, line ) {
300 this.editor = editor;
306 this.tag = this.contents.substr( 0, 3 );
307 this.isControlField = ( this.tag < '010' );
309 if ( this.isControlField ) {
310 this._ind1 = this.contents.substr( 4, 1 );
311 this._ind2 = this.contents.substr( 6, 1 );
317 this.subfields = null;
320 $.extend( EditorField.prototype, {
321 _subfieldsInvalid: function() {
322 return !this.subfields;
324 _invalidateSubfields: function() {
325 this._subfields = null;
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;
333 _scanSubfields: function() {
336 if ( this.isControlField ) {
337 this._subfields = [ new EditorSubfield( this, 0, 4, this.contents.length ) ];
340 var subfields = this.info.subfields;
341 this._subfields = [];
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;
346 this._subfields.push( new EditorSubfield( this, i, subfields[i].ch, end ) );
352 this.cm.replaceRange( "", { line: this.line, ch: 0 }, { line: this.line + 1, ch: 0 }, 'marcAware' );
355 this.cm.setCursor( { line: this.line, ch: 0 } );
360 getText: function() {
363 $.each( this.getSubfields(), function() {
364 if ( this.code != '@' ) result += '‡' + this.code;
366 result += this.getText();
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] );
379 this.cm.replaceRange( text, { line: this.line, ch: this.isControlField ? 4 : 8 }, { line: this.line }, 'marcAware' );
380 this._invalidateSubfields();
385 getIndicator1: function() {
388 getIndicator2: function() {
391 setIndicator1: function(val) {
392 if ( this.isControlField ) throw new FieldError('Cannot set indicators on control field');
394 this._ind1 = ( !val || val == ' ' ) ? '_' : val;
395 this.cm.replaceRange( this._ind1, { line: this.line, ch: 4 }, { line: this.line, ch: 5 }, 'marcAware' );
399 setIndicator2: function(val) {
400 if ( this.isControlField ) throw new FieldError('Cannot set indicators on control field');
402 this._ind2 = ( !val || val == ' ' ) ? '_' : val;
403 this.cm.replaceRange( this._ind2, { line: this.line, ch: 6 }, { line: this.line, ch: 7 }, 'marcAware' );
408 appendSubfield: function( code ) {
409 if ( this.isControlField ) throw new FieldError('Cannot add subfields to control field');
411 this._invalidateSubfields();
412 this.cm.replaceRange( '‡' + code, { line: this.line }, null, 'marcAware' );
413 var subfields = this.getSubfields();
415 return subfields[ subfields.length - 1 ];
417 insertSubfield: function( code, position ) {
418 if ( this.isControlField ) throw new FieldError('Cannot add subfields to control field');
420 position = position || 0;
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();
427 return subfields[ position ];
429 getSubfields: function( code ) {
430 if ( !this._subfields ) this._scanSubfields();
431 if ( code == null ) return this._subfields;
435 $.each( this._subfields, function() {
436 if ( code == null || this.code == code ) result.push(this);
441 getFirstSubfield: function( code ) {
442 var result = this.getSubfields( code );
444 return ( result && result.length ) ? result[0] : null;
446 getSubfieldAt: function( ch ) {
447 var subfields = this.getSubfields();
449 for (var i = 0; i < subfields.length; i++) {
450 if ( subfields[i].start < ch && subfields[i].end >= ch ) return subfields[i];
455 function MARCEditor( options ) {
456 this.cm = CodeMirror(
459 extraKeys: _editorKeys,
461 'modified-line-gutter',
466 nonRepeatableTags: KohaBackend.GetTagsBy( '', 'repeatable', '0' ),
467 nonRepeatableSubfields: KohaBackend.GetSubfieldsBy( '', 'repeatable', '0' )
471 this.cm.marceditor = this;
473 this.cm.on( 'beforeChange', editorBeforeChange );
474 this.cm.on( 'changes', editorChanges );
475 this.cm.on( 'cursorActivity', editorCursorActivity );
476 this.cm.on( 'overwriteToggle', editorSetOverwriteMode );
478 this.onCursorActivity = options.onCursorActivity;
480 this.subscribers = [];
481 this.subscribe( function( marceditor ) {
482 Widget.Notify( marceditor );
486 MARCEditor.FieldError = FieldError;
488 $.extend( MARCEditor.prototype, {
489 setUseWidgets: function( val ) {
491 for ( var line = 0; line <= this.cm.lastLine(); line++ ) {
492 Widget.UpdateLine( this, line );
495 $.each( this.cm.getAllMarks(), function( undef, mark ) {
496 if ( mark.widget ) mark.widget.clearToText();
505 getCursor: function() {
506 return this.cm.getCursor();
509 refresh: function() {
513 displayRecord: function( record ) {
514 this.cm.setValue( TextMARC.RecordToText(record) );
515 this.modified = false;
518 getRecord: function() {
519 this.textMode = true;
521 $.each( this.cm.getAllMarks(), function( undef, mark ) {
522 if ( mark.widget ) mark.widget.clearToText();
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 );
529 this.textMode = false;
534 getLineInfo: function( pos ) {
535 var contents = this.cm.getLine( pos.line );
536 if ( contents == null ) return {};
538 var tagNumber = contents.match( /^([A-Za-z0-9]{3})/ );
540 if ( !tagNumber ) return null; // No tag at all on this line
541 tagNumber = tagNumber[1];
543 if ( tagNumber < '010' ) return { tagNumber: tagNumber, contents: contents }; // No current subfield
545 var matcher = /‡([a-z0-9%])/g;
551 while ( ( match = matcher.exec(contents) ) ) {
552 subfields.push( { code: match[1], ch: match.index } );
553 if ( match.index < pos.ch ) currentSubfield = match[1];
556 return { tagNumber: tagNumber, subfields: subfields, currentSubfield: currentSubfield, contents: contents };
559 addError: function( line, error ) {
563 if ( line == null ) {
565 options.above = true;
568 $.each( this.cm.getLineHandle(line).widgets || [], function( undef, widget ) {
569 if ( !widget.isErrorMarker ) return;
573 $( widget.node ).append( '; ' + error );
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 );
585 widget.isErrorMarker = true;
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();
596 startNotify: function() {
597 if ( this.notifyTimeout ) clearTimeout( this.notifyTimeout );
598 this.notifyTimeout = setTimeout( $.proxy( function() {
601 this.notifyTimeout = null;
602 }, this ), NOTIFY_TIMEOUT );
605 notifyAll: function() {
606 $.each( this.subscribers, $.proxy( function( undef, subscriber ) {
611 subscribe: function( subscriber ) {
612 this.subscribers.push( subscriber );
615 createField: function( tag, line ) {
616 var contents = tag + ( tag < '010' ? ' ' : ' _ _ ' );
618 if ( line > this.cm.lastLine() ) {
619 contents = '\n' + contents;
621 contents = contents + '\n';
624 this.cm.replaceRange( contents, { line: line, ch: 0 }, null, 'marcAware' );
626 return new EditorField( this, line );
629 createFieldOrdered: function( tag ) {
632 for ( line = 0; (contents = this.cm.getLine(line)); line++ ) {
633 if ( contents && contents.substr(0, 3) > tag ) break;
636 return this.createField( tag, line );
639 createFieldGrouped: function( tag ) {
640 // Control fields should be inserted in actual order, whereas other fields should be
642 if ( tag < '010' ) return this.createFieldOrdered( tag );
646 for ( line = 0; (contents = this.cm.getLine(line)); line++ ) {
647 if ( contents && contents[0] > tag[0] ) break;
650 return this.createField( tag, line );
653 getFieldAt: function( line ) {
655 return new EditorField( this, line );
661 getCurrentField: function() {
662 return this.getFieldAt( this.cm.getCursor().line );
665 getFields: function( tag ) {
668 if ( tag != null ) tag += ' ';
670 for ( var line = 0; line < this.cm.lineCount(); line++ ) {
671 if ( tag && this.cm.getLine(line).substr( 0, 4 ) != tag ) continue;
673 // If this throws a FieldError, pretend it doesn't exist
675 result.push( new EditorField( this, line ) );
677 if ( !( e instanceof FieldError ) ) throw e;
684 getFirstField: function( tag ) {
685 var result = this.getFields( tag );
687 return ( result && result.length ) ? result[0] : null;
690 getAllFields: function( tag ) {
691 return this.getFields( null );