/** * Copyright 2015 ByWater Solutions * * This file is part of Koha. * * Koha is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. * * Koha is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Koha; if not, see . */ define( [ 'marc-record', 'koha-backend', 'preferences', 'text-marc', 'widget' ], function( MARC, KohaBackend, Preferences, TextMARC, Widget ) { var NOTIFY_TIMEOUT = 250; function editorCursorActivity( cm ) { var editor = cm.marceditor; var field = editor.getCurrentField(); if ( !field ) return; // Set overwrite mode for tag numbers/indicators and contents of fixed fields if ( field.isControlField || cm.getCursor().ch < 8 ) { cm.toggleOverwrite(true); } else { cm.toggleOverwrite(false); } editor.onCursorActivity(); } // This function exists to prevent inserting or partially deleting text that belongs to a // widget. The 'marcAware' change source exists for other parts of the editor code to bypass // this check. function editorBeforeChange( cm, change ) { var editor = cm.marceditor; if ( editor.textMode || change.origin == 'marcAware' || change.origin == 'widget.clearToText' ) return; // FIXME: Should only cancel changes if this is a control field/subfield widget 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 if ( change.from.ch == change.to.ch - 1 && cm.findMarksAt( { line: change.from.line, ch: change.from.ch + 1 } ).length ) { change.cancel(); } else if ( change.from.ch == change.to.ch && cm.findMarksAt(change.from).length && !change.text[0] == '‡' ) { change.cancel(); } } function editorChanges( cm, changes ) { var editor = cm.marceditor; if ( editor.textMode ) return; for (var i = 0; i < changes.length; i++) { var change = changes[i]; var origin = change.from.line; var newTo = CodeMirror.changeEnd(change); for (var delLine = origin; delLine <= change.to.line; delLine++) { // Line deleted; currently nothing to do } for (var line = origin; line <= newTo.line; line++) { if ( Preferences.user.fieldWidgets ) Widget.UpdateLine( cm.marceditor, line ); if ( change.origin != 'setValue' && change.origin != 'marcWidgetPrefill' && change.origin != 'widget.clearToText' ) { cm.addLineClass( line, 'wrapper', 'modified-line' ); editor.modified = true; } } } Widget.ActivateAt( cm, cm.getCursor() ); cm.marceditor.startNotify(); } function editorSetOverwriteMode( cm, newState ) { var editor = cm.marceditor; editor.overwriteMode = newState; } // Editor helper functions function activateTabPosition( cm, pos, idx ) { // Allow tabbing to as-yet-nonexistent positions var lenDiff = pos.ch - cm.getLine( pos.line ).length; if ( lenDiff > 0 ) { var extra = ''; while ( lenDiff-- > 0 ) extra += ' '; if ( pos.prefill ) extra += pos.prefill; cm.replaceRange( extra, { line: pos.line } ); } cm.setCursor( pos ); Widget.ActivateAt( cm, pos, idx ); } function getTabPositions( editor, cur ) { cur = cur || editor.cm.getCursor(); var field = editor.getFieldAt( cur.line ); if ( field ) { if ( field.isControlField ) { var positions = [ { ch: 0 }, { ch: 4 } ]; $.each( positions, function( undef, pos ) { pos.line = cur.line; } ); return positions; } else { var positions = [ { ch: 0 }, { ch: 4, prefill: '_' }, { ch: 6, prefill: '_' } ]; $.each( positions, function( undef, pos ) { pos.line = cur.line; } ); $.each( field.getSubfields(), function( undef, subfield ) { positions.push( { line: cur.line, ch: subfield.contentsStart } ); } ); // Allow to tab to start of empty field if ( field.getSubfields().length == 0 ) { positions.push( { line: cur.line, ch: 8 } ); } return positions; } } else { return []; } } var _editorKeys = { Enter: function( cm ) { var cursor = cm.getCursor(); cm.replaceRange( '\n', { line: cursor.line }, null, 'marcAware' ); cm.setCursor( { line: cursor.line + 1, ch: 0 } ); }, 'Shift-Enter': function( cm ) { var cur = cm.getCursor(); cm.replaceRange( "\n", cur, null ); }, 'Ctrl-X': function( cm ) { // Delete line (or cut) if ( cm.somethingSelected() ) return true; cm.execCommand('deleteLine'); }, 'Shift-Ctrl-X': function( cm ) { // Delete subfield var field = cm.marceditor.getCurrentField(); if ( !field ) return; var subfield = field.getSubfieldAt( cm.getCursor().ch ); if ( subfield ) subfield.delete(); }, Tab: function( cm ) { // Move through parts of tag/fixed fields var positions = getTabPositions( cm.marceditor ); var cur = cm.getCursor(); for ( var i = 0; i < positions.length; i++ ) { if ( positions[i].ch > cur.ch ) { activateTabPosition( cm, positions[i] ); return false; } } cm.setCursor( { line: cur.line + 1, ch: 0 } ); }, 'Shift-Tab': function( cm ) { // Move backwards through parts of tag/fixed fields var positions = getTabPositions( cm.marceditor ); var cur = cm.getCursor(); for ( var i = positions.length - 1; i >= 0; i-- ) { if ( positions[i].ch < cur.ch ) { activateTabPosition( cm, positions[i], -1 ); return false; } } if ( cur.line == 0 ) return; var prevPositions = getTabPositions( cm.marceditor, { line: cur.line - 1, ch: cm.getLine( cur.line - 1 ).length } ); if ( prevPositions.length ) { activateTabPosition( cm, prevPositions[ prevPositions.length - 1 ], -1 ); } else { cm.setCursor( { line: cur.line - 1, ch: 0 } ); } }, 'Ctrl-D': function( cm ) { // Insert subfield delimiter var cur = cm.getCursor(); cm.replaceRange( "‡", cur, null ); }, }; // The objects below are part of a field/subfield manipulation API, accessed through the base // editor object. // // Each one is tied to a particular line; this means that using a field or subfield object after // any other changes to the record will cause entertaining explosions. The objects are meant to // be temporary, and should only be reused with great care. The macro code does this only // because it is careful to dispose of the object after any other updates. // // Note, however, tha you can continue to use a field object after changing subfields. It's just // the subfield objects that become invalid. // This is an exception raised by the EditorSubfield and EditorField when an invalid change is // attempted. function FieldError(line, message) { this.line = line; this.message = message; }; FieldError.prototype.toString = function() { return 'FieldError(' + this.line + ', "' + this.message + '")'; }; // This is the temporary object for a particular subfield in a field. Any change to any other // subfields will invalidate this subfield object. function EditorSubfield( field, index, start, end ) { this.field = field; this.index = index; this.start = start; this.end = end; if ( this.field.isControlField ) { this.contentsStart = start; this.code = '@'; } else { this.contentsStart = start + 2; this.code = this.field.contents.substr( this.start + 1, 1 ); } this.cm = field.cm; var marks = this.cm.findMarksAt( { line: field.line, ch: this.contentsStart } ); if ( marks[0] && marks[0].widget ) { this.widget = marks[0].widget; this.text = this.widget.text; this.setText = this.widget.setText; this.getFixed = this.widget.getFixed; this.setFixed = this.widget.setFixed; } else { this.widget = null; this.text = this.field.contents.substr( this.contentsStart, end - this.contentsStart ); } }; $.extend( EditorSubfield.prototype, { _invalid: function() { return this.field._subfieldsInvalid(); }, delete: function() { this.cm.replaceRange( "", { line: this.field.line, ch: this.start }, { line: this.field.line, ch: this.end }, 'marcAware' ); }, focus: function() { this.cm.setCursor( { line: this.field.line, ch: this.contentsStart } ); }, focusEnd: function() { this.cm.setCursor( { line: this.field.line, ch: this.end } ); }, getText: function() { return this.text; }, setText: function( text ) { if ( !this._invalid() ) throw new FieldError( this.field.line, 'subfield invalid' ); this.cm.replaceRange( text, { line: this.field.line, ch: this.contentsStart }, { line: this.field.line, ch: this.end }, 'marcAware' ); this.field._invalidateSubfields(); }, } ); function EditorField( editor, line ) { this.editor = editor; this.line = line; this.cm = editor.cm; this._updateInfo(); this.tag = this.contents.substr( 0, 3 ); this.isControlField = ( this.tag < '010' ); if ( this.isControlField ) { this._ind1 = this.contents.substr( 4, 1 ); this._ind2 = this.contents.substr( 6, 1 ); } else { this._ind1 = null; this._ind2 = null; } this.subfields = null; } $.extend( EditorField.prototype, { _subfieldsInvalid: function() { return !this.subfields; }, _invalidateSubfields: function() { this._subfields = null; }, _updateInfo: function() { this.info = this.editor.getLineInfo( { line: this.line, ch: 0 } ); if ( this.info == null ) throw new FieldError( 'Invalid field' ); this.contents = this.info.contents; }, _scanSubfields: function() { this._updateInfo(); if ( this.isControlField ) { this._subfields = [ new EditorSubfield( this, 0, 4, this.contents.length ) ]; } else { var field = this; var subfields = this.info.subfields; this._subfields = []; for (var i = 0; i < this.info.subfields.length; i++) { var end = i == subfields.length - 1 ? this.contents.length : subfields[i+1].ch; this._subfields.push( new EditorSubfield( this, i, subfields[i].ch, end ) ); } } }, delete: function() { this.cm.replaceRange( "", { line: this.line, ch: 0 }, { line: this.line + 1, ch: 0 }, 'marcAware' ); }, focus: function() { this.cm.setCursor( { line: this.line, ch: 0 } ); return this; }, getText: function() { var result = ''; $.each( this.getSubfields(), function() { if ( this.code != '@' ) result += '‡' + this.code; result += this.getText(); } ); return result; }, setText: function( text ) { var indicator_match = /^([_ 0-9])([_ 0-9])\‡/.exec( text ); if ( indicator_match ) { text = text.substr(2); this.setIndicator1( indicator_match[1] ); this.setIndicator2( indicator_match[2] ); } this.cm.replaceRange( text, { line: this.line, ch: this.isControlField ? 4 : 8 }, { line: this.line }, 'marcAware' ); this._invalidateSubfields(); return this; }, getIndicator1: function() { return this._ind1; }, getIndicator2: function() { return this._ind2; }, setIndicator1: function(val) { if ( this.isControlField ) throw new FieldError('Cannot set indicators on control field'); this._ind1 = ( !val || val == ' ' ) ? '_' : val; this.cm.replaceRange( this._ind1, { line: this.line, ch: 4 }, { line: this.line, ch: 5 }, 'marcAware' ); return this; }, setIndicator2: function(val) { if ( this.isControlField ) throw new FieldError('Cannot set indicators on control field'); this._ind2 = ( !val || val == ' ' ) ? '_' : val; this.cm.replaceRange( this._ind2, { line: this.line, ch: 6 }, { line: this.line, ch: 7 }, 'marcAware' ); return this; }, appendSubfield: function( code ) { if ( this.isControlField ) throw new FieldError('Cannot add subfields to control field'); this._invalidateSubfields(); this.cm.replaceRange( '‡' + code, { line: this.line }, null, 'marcAware' ); var subfields = this.getSubfields(); return subfields[ subfields.length - 1 ]; }, insertSubfield: function( code, position ) { if ( this.isControlField ) throw new FieldError('Cannot add subfields to control field'); position = position || 0; var subfields = this.getSubfields(); this._invalidateSubfields(); this.cm.replaceRange( '‡' + code, { line: this.line, ch: subfields[position] ? subfields[position].start : null }, null, 'marcAware' ); subfields = this.getSubfields(); return subfields[ position ]; }, getSubfields: function( code ) { if ( !this._subfields ) this._scanSubfields(); if ( code == null ) return this._subfields; var result = []; $.each( this._subfields, function() { if ( code == null || this.code == code ) result.push(this); } ); return result; }, getFirstSubfield: function( code ) { var result = this.getSubfields( code ); return ( result && result.length ) ? result[0] : null; }, getSubfieldAt: function( ch ) { var subfields = this.getSubfields(); for (var i = 0; i < subfields.length; i++) { if ( subfields[i].start < ch && subfields[i].end >= ch ) return subfields[i]; } }, } ); function MARCEditor( options ) { this.cm = CodeMirror( options.position, { extraKeys: _editorKeys, gutters: [ 'modified-line-gutter', ], lineWrapping: true, mode: { name: 'marc', nonRepeatableTags: KohaBackend.GetTagsBy( '', 'repeatable', '0' ), nonRepeatableSubfields: KohaBackend.GetSubfieldsBy( '', 'repeatable', '0' ) } } ); this.cm.marceditor = this; this.cm.on( 'beforeChange', editorBeforeChange ); this.cm.on( 'changes', editorChanges ); this.cm.on( 'cursorActivity', editorCursorActivity ); this.cm.on( 'overwriteToggle', editorSetOverwriteMode ); this.onCursorActivity = options.onCursorActivity; this.subscribers = []; this.subscribe( function( marceditor ) { Widget.Notify( marceditor ); } ); } MARCEditor.FieldError = FieldError; $.extend( MARCEditor.prototype, { setUseWidgets: function( val ) { if ( val ) { for ( var line = 0; line <= this.cm.lastLine(); line++ ) { Widget.UpdateLine( this, line ); } } else { $.each( this.cm.getAllMarks(), function( undef, mark ) { if ( mark.widget ) mark.widget.clearToText(); } ); } }, focus: function() { this.cm.focus(); }, getCursor: function() { return this.cm.getCursor(); }, refresh: function() { this.cm.refresh(); }, displayRecord: function( record ) { this.cm.setValue( TextMARC.RecordToText(record) ); this.modified = false; }, getRecord: function() { this.textMode = true; $.each( this.cm.getAllMarks(), function( undef, mark ) { if ( mark.widget ) mark.widget.clearToText(); } ); var record = TextMARC.TextToRecord( this.cm.getValue() ); for ( var line = 0; line <= this.cm.lastLine(); line++ ) { if ( Preferences.user.fieldWidgets ) Widget.UpdateLine( this, line ); } this.textMode = false; return record; }, getLineInfo: function( pos ) { var contents = this.cm.getLine( pos.line ); if ( contents == null ) return {}; var tagNumber = contents.match( /^([A-Za-z0-9]{3})/ ); if ( !tagNumber ) return null; // No tag at all on this line tagNumber = tagNumber[1]; if ( tagNumber < '010' ) return { tagNumber: tagNumber, contents: contents }; // No current subfield var matcher = /‡([a-z0-9%])/g; var match; var subfields = []; var currentSubfield; while ( ( match = matcher.exec(contents) ) ) { subfields.push( { code: match[1], ch: match.index } ); if ( match.index < pos.ch ) currentSubfield = match[1]; } return { tagNumber: tagNumber, subfields: subfields, currentSubfield: currentSubfield, contents: contents }; }, addError: function( line, error ) { var found = false; var options = {}; if ( line == null ) { line = 0; options.above = true; } $.each( this.cm.getLineHandle(line).widgets || [], function( undef, widget ) { if ( !widget.isErrorMarker ) return; found = true; $( widget.node ).append( '; ' + error ); widget.changed(); return false; } ); if ( found ) return; var node = $( '
' + error + '
' )[0]; var widget = this.cm.addLineWidget( line, node, options ); widget.node = node; widget.isErrorMarker = true; }, removeErrors: function() { for ( var line = 0; line < this.cm.lineCount(); line++ ) { $.each( this.cm.getLineHandle( line ).widgets || [], function( undef, lineWidget ) { if ( lineWidget.isErrorMarker ) lineWidget.clear(); } ); } }, startNotify: function() { if ( this.notifyTimeout ) clearTimeout( this.notifyTimeout ); this.notifyTimeout = setTimeout( $.proxy( function() { this.notifyAll(); this.notifyTimeout = null; }, this ), NOTIFY_TIMEOUT ); }, notifyAll: function() { $.each( this.subscribers, $.proxy( function( undef, subscriber ) { subscriber(this); }, this ) ); }, subscribe: function( subscriber ) { this.subscribers.push( subscriber ); }, createField: function( tag, line ) { var contents = tag + ( tag < '010' ? ' ' : ' _ _ ' ); if ( line > this.cm.lastLine() ) { contents = '\n' + contents; } else { contents = contents + '\n'; } this.cm.replaceRange( contents, { line: line, ch: 0 }, null, 'marcAware' ); return new EditorField( this, line ); }, createFieldOrdered: function( tag ) { var line, contents; for ( line = 0; (contents = this.cm.getLine(line)); line++ ) { if ( contents && contents.substr(0, 3) > tag ) break; } return this.createField( tag, line ); }, createFieldGrouped: function( tag ) { // Control fields should be inserted in actual order, whereas other fields should be // inserted grouped if ( tag < '010' ) return this.createFieldOrdered( tag ); var line, contents; for ( line = 0; (contents = this.cm.getLine(line)); line++ ) { if ( contents && contents[0] > tag[0] ) break; } return this.createField( tag, line ); }, getFieldAt: function( line ) { try { return new EditorField( this, line ); } catch (e) { return null; } }, getCurrentField: function() { return this.getFieldAt( this.cm.getCursor().line ); }, getFields: function( tag ) { var result = []; if ( tag != null ) tag += ' '; for ( var line = 0; line < this.cm.lineCount(); line++ ) { if ( tag && this.cm.getLine(line).substr( 0, 4 ) != tag ) continue; // If this throws a FieldError, pretend it doesn't exist try { result.push( new EditorField( this, line ) ); } catch (e) { if ( !( e instanceof FieldError ) ) throw e; } } return result; }, getFirstField: function( tag ) { var result = this.getFields( tag ); return ( result && result.length ) ? result[0] : null; }, getAllFields: function( tag ) { return this.getFields( null ); }, } ); return MARCEditor; } );