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