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