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