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