]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
LP1852782 MARC edit inline authority record creation.
[Evergreen.git] / Open-ILS / src / eg2 / src / app / staff / share / marc-edit / editable-content.component.ts
1 import {ElementRef, Component, Input, Output, OnInit, OnDestroy,
2     ViewChild, EventEmitter, AfterViewInit, Renderer2} from '@angular/core';
3 import {Subscription} from 'rxjs';
4 import {filter} from 'rxjs/operators';
5 import {MarcRecord, MarcField, MarcSubfield} from './marcrecord';
6 import {MarcEditContext, FieldFocusRequest, MARC_EDITABLE_FIELD_TYPE,
7     TextUndoRedoAction} from './editor-context';
8 import {ContextMenuEntry} from '@eg/share/context-menu/context-menu.service';
9 import {StringComponent} from '@eg/share/string/string.component';
10 import {TagTable} from './tagtable.service';
11
12 /**
13  * MARC Editable Content Component
14  */
15
16 @Component({
17   selector: 'eg-marc-editable-content',
18   templateUrl: './editable-content.component.html',
19   styleUrls: ['./editable-content.component.css']
20 })
21
22 export class EditableContentComponent
23     implements OnInit, AfterViewInit, OnDestroy {
24
25     @Input() context: MarcEditContext;
26     @Input() field: MarcField;
27     @Input() fieldType: MARC_EDITABLE_FIELD_TYPE = null;
28
29     // read-only field text.  E.g. 'LDR'
30     @Input() fieldText: string = null;
31
32     // array of subfield code and subfield value
33     @Input() subfield: MarcSubfield;
34
35     @Input() fixedFieldCode: string;
36
37     // space-separated list of additional CSS classes to append
38     @Input() moreClasses: string;
39
40     // aria-label text.  This will not be visible in the UI.
41     @Input() ariaLabel: string;
42
43     get record(): MarcRecord { return this.context.record; }
44
45     bigText = false;
46     randId = Math.floor(Math.random() * 100000);
47     editInput: any; // <input/> or <div contenteditable/>
48     maxLength: number = null;
49
50     // Track the load-time content so we know what text value to
51     // track on our undo stack.
52     undoBackToText: string;
53
54     focusSub: Subscription;
55     undoRedoSub: Subscription;
56     isLeader: boolean; // convenience
57
58     // Cache of fixed field menu options
59     ffValues: ContextMenuEntry[] = [];
60
61     // Cache of tag context menu entries
62     tagMenuEntries: ContextMenuEntry[] = [];
63
64     // Track the fixed field value locally since extracting the value
65     // in real time from the record, which adds padding to the text,
66     // causes usability problems.
67     ffValue: string;
68
69     @ViewChild('add006', {static: false}) add006Str: StringComponent;
70     @ViewChild('add007', {static: false}) add007Str: StringComponent;
71     @ViewChild('add008', {static: false}) add008Str: StringComponent;
72     @ViewChild('insertBefore', {static: false}) insertBeforeStr: StringComponent;
73     @ViewChild('insertAfter', {static: false}) insertAfterStr: StringComponent;
74     @ViewChild('deleteField', {static: false}) deleteFieldStr: StringComponent;
75
76     constructor(private renderer: Renderer2) {}
77
78     tt(): TagTable { // for brevity
79         return this.context.tagTable;
80     }
81
82     ngOnInit() {
83         this.setupFieldType();
84     }
85
86     ngOnDestroy() {
87         if (this.focusSub) { this.focusSub.unsubscribe(); }
88         if (this.undoRedoSub) { this.undoRedoSub.unsubscribe(); }
89     }
90
91     watchForFocusRequests() {
92         this.focusSub = this.context.fieldFocusRequest.pipe(
93             filter((req: FieldFocusRequest) => this.focusRequestIsMe(req)))
94         .subscribe((req: FieldFocusRequest) => this.selectText(req));
95     }
96
97     watchForUndoRedoRequests() {
98         this.undoRedoSub = this.context.textUndoRedoRequest.pipe(
99             filter((action: TextUndoRedoAction) => this.focusRequestIsMe(action.position)))
100         .subscribe((action: TextUndoRedoAction) => this.processUndoRedo(action));
101     }
102
103     focusRequestIsMe(req: FieldFocusRequest): boolean {
104         if (req.target !== this.fieldType) { return false; }
105
106         if (this.field) {
107             if (req.fieldId !== this.field.fieldId) { return false; }
108         } else if (req.target === 'ldr') {
109             return this.isLeader;
110         } else if (req.target === 'ffld' &&
111             req.ffCode !== this.fixedFieldCode) {
112             return false;
113         }
114
115         if (req.sfOffset !== undefined &&
116             req.sfOffset !== this.subfield[2]) {
117             // this is not the subfield you are looking for.
118             return false;
119         }
120
121         return true;
122     }
123
124     selectText(req?: FieldFocusRequest) {
125         if (this.bigText) {
126             this.focusBigText();
127         } else {
128             this.editInput.select();
129         }
130
131         if (!req) {
132             // Focus request may have come from keyboard navigation,
133             // clicking, etc.  Model the event as a focus request
134             // so it can be tracked the same.
135             req = {
136                 fieldId: this.field ? this.field.fieldId : -1,
137                 target: this.fieldType,
138                 sfOffset: this.subfield ? this.subfield[2] : undefined,
139                 ffCode: this.fixedFieldCode
140             };
141         }
142
143         this.context.lastFocused = req;
144     }
145
146     setupFieldType() {
147         const content = this.getContent();
148         this.undoBackToText = content;
149
150         switch (this.fieldType) {
151             case 'ldr':
152                 this.isLeader = true;
153                 if (content) { this.maxLength = content.length; }
154                 this.watchForFocusRequests();
155                 this.watchForUndoRedoRequests();
156                 break;
157
158             case 'tag':
159                 this.maxLength = 3;
160                 this.watchForFocusRequests();
161                 this.watchForUndoRedoRequests();
162                 break;
163
164             case 'cfld':
165                 this.watchForFocusRequests();
166                 this.watchForUndoRedoRequests();
167                 break;
168
169             case 'ffld':
170                 this.applyFFOptions();
171                 this.watchForFocusRequests();
172                 this.watchForUndoRedoRequests();
173                 break;
174
175             case 'ind1':
176             case 'ind2':
177                 this.maxLength = 1;
178                 this.watchForFocusRequests();
179                 this.watchForUndoRedoRequests();
180                 break;
181
182             case 'sfc':
183                 this.maxLength = 1;
184                 this.watchForFocusRequests();
185                 this.watchForUndoRedoRequests();
186                 break;
187
188             case 'sfv':
189                 this.bigText = true;
190                 this.watchForFocusRequests();
191                 this.watchForUndoRedoRequests();
192                 break;
193
194             default:
195                 if (this.fieldText) {
196                     this.maxLength = this.fieldText.length;
197                 }
198         }
199     }
200
201     applyFFOptions() {
202         return this.tt().getFfFieldMeta(this.fixedFieldCode)
203         .then(fieldMeta => {
204             if (fieldMeta) {
205                 this.maxLength = fieldMeta.length || 1;
206             }
207         });
208     }
209
210     // These are served dynamically to handle cases where a tag or
211     // subfield is modified in place.
212     contextMenuEntries(): ContextMenuEntry[] {
213         if (this.isLeader) { return; }
214
215         switch (this.fieldType) {
216             case 'tag':
217                 return this.tagContextMenuEntries();
218
219             case 'sfc':
220                 return this.tt().getSubfieldCodes(this.field.tag);
221
222             case 'sfv':
223                 return this.tt().getSubfieldValues(
224                     this.field.tag, this.subfield[0]);
225
226             case 'ind1':
227             case 'ind2':
228                 return this.tt().getIndicatorValues(
229                     this.field.tag, this.fieldType);
230
231             case 'ffld':
232                 return this.tt().getFfValues(this.fixedFieldCode);
233         }
234
235         return null;
236     }
237
238     tagContextMenuEntries(): ContextMenuEntry[] {
239
240         // string components may not yet be loaded.
241         if (this.tagMenuEntries.length > 0 || !this.add006Str) {
242             return this.tagMenuEntries;
243         }
244
245         this.tagMenuEntries.push(
246             {label: this.add006Str.text, value: '_add006'},
247             {label: this.add007Str.text, value: '_add007'},
248             {label: this.add008Str.text, value: '_add008'}
249         );
250
251         if (!this.field.isCtrlField) {
252             // Only data field tags get these.
253             this.tagMenuEntries.push(
254                 {label: this.insertAfterStr.text,  value: '_insertAfter'},
255                 {label: this.insertBeforeStr.text, value: '_insertBefore'}
256             );
257         }
258
259         this.tagMenuEntries.push(
260             {label: this.deleteFieldStr.text,  value: '_deleteField'},
261             {divider: true}
262         );
263
264         this.tt().getFieldTags().forEach(e => this.tagMenuEntries.push(e));
265
266         return this.tagMenuEntries;
267     }
268
269     getContent(): string {
270         if (this.fieldText) { return this.fieldText; } // read-only
271
272         switch (this.fieldType) {
273             case 'ldr': return this.record.leader;
274             case 'cfld': return this.field.data;
275             case 'tag': return this.field.tag;
276             case 'sfc': return this.subfield[0];
277             case 'sfv': return this.subfield[1];
278             case 'ind1': return this.field.ind1;
279             case 'ind2': return this.field.ind2;
280
281             case 'ffld':
282                 // When actively editing a fixed field, track its value
283                 // in a local variable instead of pulling the value
284                 // from record.extractFixedField(), which applies
285                 // additional formattting, causing usability problems
286                 // (e.g. unexpected spaces).  Once focus is gone, the
287                 // view will be updated with the correctly formatted
288                 // value.
289
290                 if ( this.ffValue === undefined ||
291                     !this.context.lastFocused ||
292                     !this.focusRequestIsMe(this.context.lastFocused)) {
293
294                     this.ffValue =
295                         this.record.extractFixedField(this.fixedFieldCode);
296                 }
297                 return this.ffValue;
298         }
299         return 'X';
300     }
301
302     setContent(value: string, propagatBigText?: boolean, skipUndoTrack?: boolean) {
303
304         if (this.fieldText) { return; } // read-only text
305
306         switch (this.fieldType) {
307             case 'ldr': this.record.leader = value; break;
308             case 'cfld': this.field.data = value; break;
309             case 'tag': this.field.tag = value; break;
310             case 'sfc': this.subfield[0] = value; break;
311             case 'sfv': this.subfield[1] = value; break;
312             case 'ind1': this.field.ind1 = value; break;
313             case 'ind2': this.field.ind2 = value; break;
314             case 'ffld':
315                 // Track locally and propagate to the record.
316                 this.ffValue = value;
317                 this.record.setFixedField(this.fixedFieldCode, value);
318                 break;
319         }
320
321         if (propagatBigText && this.bigText) {
322             // Propagate new content to the bigtext div.
323             // Should only be used when a content change occurrs via
324             // external means (i.e. not from a direct edit of the div).
325             this.editInput.innerText = value;
326         }
327
328         if (!skipUndoTrack) {
329             this.trackTextChangeForUndo(value);
330         }
331     }
332
333     trackTextChangeForUndo(value: string) {
334
335         // Human-driven changes invalidate the redo stack.
336         this.context.redoStack = [];
337
338         const lastUndo = this.context.undoStack[0];
339
340         if (lastUndo
341             && lastUndo instanceof TextUndoRedoAction
342             && lastUndo.textContent === this.undoBackToText
343             && this.focusRequestIsMe(lastUndo.position)) {
344             // Most recent undo entry was a text change event within the
345             // current atomic editing (focused) session for the input.
346             // Nothing else to track.
347             return;
348         }
349
350         const undo = new TextUndoRedoAction();
351         undo.position = this.context.lastFocused;
352         undo.textContent =  this.undoBackToText;
353
354         this.context.addToUndoStack(undo);
355     }
356
357     // Apply the undo or redo action and track its opposite
358     // action on the necessary stack
359     processUndoRedo(action: TextUndoRedoAction) {
360
361         // Undoing a text change
362         const recoverContent = this.getContent();
363         this.setContent(action.textContent, true, true);
364
365         action.textContent = recoverContent;
366         const moveTo = action.isRedo ?
367             this.context.undoStack : this.context.redoStack;
368
369         moveTo.unshift(action);
370     }
371
372     inputBlurred() {
373         // If the text content changed during this focus session,
374         // track the new value as the value the next session of
375         // text edits should return to upon undo.
376         this.undoBackToText = this.getContent();
377     }
378
379     // Propagate editable div content into our record
380     bigTextValueChange() {
381         this.setContent(this.editInput.innerText);
382     }
383
384     ngAfterViewInit() {
385         this.editInput = // numeric id requires [id=...] query selector
386             this.renderer.selectRootElement(`[id='${this.randId}']`);
387
388         // Initialize the editable div
389         this.editInput.innerText = this.getContent();
390     }
391
392     inputSize(): number {
393         if (this.maxLength) {
394             return this.maxLength + 1;
395         }
396         // give at least 2+ chars space and grow with the content
397         return Math.max(2, (this.getContent() || '').length) * 1.1;
398     }
399
400     focusBigText() {
401         const targetNode = this.editInput.firstChild;
402
403         if (!targetNode) {
404             // Div contains no text content, nothing to select
405             return;
406         }
407
408         const range = document.createRange();
409         range.setStart(targetNode, 0);
410         range.setEnd(targetNode, targetNode.length);
411
412         const selection = window.getSelection();
413         selection.removeAllRanges();
414         selection.addRange(range);
415     }
416
417     // Route keydown events to the appropriate handler
418     inputKeyDown(evt: KeyboardEvent) {
419
420         switch (evt.key) {
421             case 'y':
422                 if (evt.ctrlKey) { // redo
423                     this.context.requestRedo();
424                     evt.preventDefault();
425                 }
426                 return;
427
428             case 'z':
429                 if (evt.ctrlKey) { // undo
430                     this.context.requestUndo();
431                     evt.preventDefault();
432                 }
433                 return;
434
435             case 'F6':
436                 if (evt.shiftKey) {
437                     // shift+F6 => add 006
438                     this.context.add00X('006');
439                     evt.preventDefault();
440                     evt.stopPropagation();
441                 }
442                 return;
443
444             case 'F7':
445                 if (evt.shiftKey) {
446                     // shift+F7 => add 007
447                     this.context.add00X('007');
448                     evt.preventDefault();
449                     evt.stopPropagation();
450                 }
451                 return;
452
453             case 'F8':
454                 if (evt.shiftKey) {
455                     // shift+F8 => add/replace 008
456                     this.context.insertReplace008();
457                     evt.preventDefault();
458                     evt.stopPropagation();
459                 }
460                 return;
461         }
462
463         // None of the remaining key combos are supported by the LDR
464         // or fixed field editor.
465         if (this.fieldType === 'ldr' || this.fieldType === 'ffld') { return; }
466
467         switch (evt.key) {
468
469             case 'Enter':
470                 if (evt.ctrlKey) {
471                     // ctrl+enter == insert stub field after focused field
472                     // ctrl+shift+enter == insert stub field before focused field
473                     this.context.insertStubField(this.field, evt.shiftKey);
474                 }
475
476                 evt.preventDefault(); // Bare newlines not allowed.
477                 break;
478
479             case 'Delete':
480
481                 if (evt.ctrlKey) {
482                     // ctrl+delete == delete whole field
483                     this.context.deleteField(this.field);
484                     evt.preventDefault();
485
486                 } else if (evt.shiftKey) {
487
488                     if (this.subfield) {
489                         // shift+delete == delete subfield
490
491                         this.context.deleteSubfield(this.field, this.subfield);
492                     }
493                     // prevent any shift-delete from bubbling up becuase
494                     // unexpected stuff will be deleted.
495                     evt.preventDefault();
496                 }
497
498                 break;
499
500             case 'ArrowDown':
501
502                 if (evt.ctrlKey) {
503                     // ctrl+down == copy current field down one
504                     this.context.insertField(
505                         this.field, this.record.cloneField(this.field));
506                 } else {
507                     // avoid dupe focus requests
508                     this.context.focusNextTag(this.field);
509                 }
510
511                 evt.preventDefault();
512                 break;
513
514             case 'ArrowUp':
515
516                 if (evt.ctrlKey) {
517                     // ctrl+up == copy current field up one
518                     this.context.insertField(
519                         this.field, this.record.cloneField(this.field), true);
520                 } else {
521                     // avoid dupe focus requests
522                     this.context.focusPreviousTag(this.field);
523                 }
524
525                 // up == move focus to tag of previous field
526                 evt.preventDefault();
527                 break;
528
529             case 'd': // thunk
530             case 'i':
531                 if (evt.ctrlKey) {
532                     // ctrl+i / ctrl+d == insert subfield
533                     const pos = this.subfield ? this.subfield[2] + 1 : 0;
534                     this.context.insertStubSubfield(this.field, pos);
535                     evt.preventDefault();
536                 }
537                 break;
538         }
539     }
540
541     insertField(before: boolean) {
542
543         const newField = this.record.newField(
544             {tag: '999', subfields: [[' ', '', 0]]});
545
546         if (before) {
547             this.record.insertFieldsBefore(this.field, newField);
548         } else {
549             this.record.insertFieldsAfter(this.field, newField);
550         }
551
552         this.context.requestFieldFocus(
553             {fieldId: newField.fieldId, target: 'tag'});
554     }
555
556     deleteField() {
557         if (!this.context.focusNextTag(this.field)) {
558             this.context.focusPreviousTag(this.field);
559         }
560
561         this.record.deleteFields(this.field);
562     }
563
564     deleteSubfield() {
565         // If subfields remain, focus the previous subfield.
566         // otherwise focus our tag.
567         const sfpos = this.subfield[2] - 1;
568
569         this.field.deleteExactSubfields(this.subfield);
570
571         const focus: FieldFocusRequest = {
572             fieldId: this.field.fieldId, target: 'tag'};
573
574         if (sfpos >= 0) {
575             focus.target = 'sfv';
576             focus.sfOffset = sfpos;
577         }
578
579         this.context.requestFieldFocus(focus);
580     }
581
582     contextMenuChange(value: string) {
583
584         switch (value) {
585             case '_add006': return this.context.add00X('006');
586             case '_add007': return this.context.add00X('007');
587             case '_add008': return this.context.insertReplace008();
588             case '_insertBefore':
589                 return this.context.insertStubField(this.field, true);
590             case '_insertAfter':
591                 return this.context.insertStubField(this.field);
592             case '_deleteField': return this.context.deleteField(this.field);
593         }
594
595         this.setContent(value, true);
596
597         // Context menus can steal focus.
598         this.context.requestFieldFocus(this.context.lastFocused);
599     }
600
601     isAuthInvalid(): boolean {
602         return (
603             this.fieldType === 'sfv' &&
604             this.field.authChecked &&
605             !this.field.authValid
606         );
607     }
608
609     isAuthValid(): boolean {
610         return (
611             this.fieldType === 'sfv' &&
612             this.field.authChecked &&
613             this.field.authValid
614         );
615     }
616
617     isLastSubfieldValue(): boolean {
618         if (this.fieldType === 'sfv') {
619             const myIdx = this.subfield[2];
620             for (let idx = 0; idx < this.field.subfields.length; idx++) {
621                 if (idx > myIdx) {
622                     return false;
623                 }
624             }
625             return true;
626         }
627
628         return false;
629     }
630 }
631
632