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';
13 * MARC Editable Content Component
17 selector: 'eg-marc-editable-content',
18 templateUrl: './editable-content.component.html',
19 styleUrls: ['./editable-content.component.css']
22 export class EditableContentComponent
23 implements OnInit, AfterViewInit, OnDestroy {
25 @Input() context: MarcEditContext;
26 @Input() field: MarcField;
27 @Input() fieldType: MARC_EDITABLE_FIELD_TYPE = null;
29 // read-only field text. E.g. 'LDR'
30 @Input() fieldText: string = null;
32 // array of subfield code and subfield value
33 @Input() subfield: MarcSubfield;
35 @Input() fixedFieldCode: string;
37 // space-separated list of additional CSS classes to append
38 @Input() moreClasses: string;
40 // aria-label text. This will not be visible in the UI.
41 @Input() ariaLabel: string;
43 get record(): MarcRecord { return this.context.record; }
46 randId = Math.floor(Math.random() * 100000);
47 editInput: any; // <input/> or <div contenteditable/>
48 maxLength: number = null;
50 // Track the load-time content so we know what text value to
51 // track on our undo stack.
52 undoBackToText: string;
54 focusSub: Subscription;
55 undoRedoSub: Subscription;
56 isLeader: boolean; // convenience
58 // Cache of fixed field menu options
59 ffValues: ContextMenuEntry[] = [];
61 // Cache of tag context menu entries
62 tagMenuEntries: ContextMenuEntry[] = [];
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.
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;
76 constructor(private renderer: Renderer2) {}
78 tt(): TagTable { // for brevity
79 return this.context.tagTable;
83 this.setupFieldType();
87 if (this.focusSub) { this.focusSub.unsubscribe(); }
88 if (this.undoRedoSub) { this.undoRedoSub.unsubscribe(); }
91 watchForFocusRequests() {
92 this.focusSub = this.context.fieldFocusRequest.pipe(
93 filter((req: FieldFocusRequest) => this.focusRequestIsMe(req)))
94 .subscribe((req: FieldFocusRequest) => this.selectText(req));
97 watchForUndoRedoRequests() {
98 this.undoRedoSub = this.context.textUndoRedoRequest.pipe(
99 filter((action: TextUndoRedoAction) => this.focusRequestIsMe(action.position)))
100 .subscribe((action: TextUndoRedoAction) => this.processUndoRedo(action));
103 focusRequestIsMe(req: FieldFocusRequest): boolean {
104 if (req.target !== this.fieldType) { return false; }
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) {
115 if (req.sfOffset !== undefined &&
116 req.sfOffset !== this.subfield[2]) {
117 // this is not the subfield you are looking for.
124 selectText(req?: FieldFocusRequest) {
128 this.editInput.select();
133 this.setContent(req.newText);
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.
141 fieldId: this.field ? this.field.fieldId : -1,
142 target: this.fieldType,
143 sfOffset: this.subfield ? this.subfield[2] : undefined,
144 ffCode: this.fixedFieldCode
148 this.context.lastFocused = req;
152 const content = this.getContent();
153 this.undoBackToText = content;
155 switch (this.fieldType) {
157 this.isLeader = true;
158 if (content) { this.maxLength = content.length; }
159 this.watchForFocusRequests();
160 this.watchForUndoRedoRequests();
165 this.watchForFocusRequests();
166 this.watchForUndoRedoRequests();
170 this.watchForFocusRequests();
171 this.watchForUndoRedoRequests();
175 this.applyFFOptions();
176 this.watchForFocusRequests();
177 this.watchForUndoRedoRequests();
183 this.watchForFocusRequests();
184 this.watchForUndoRedoRequests();
189 this.watchForFocusRequests();
190 this.watchForUndoRedoRequests();
195 this.watchForFocusRequests();
196 this.watchForUndoRedoRequests();
200 if (this.fieldText) {
201 this.maxLength = this.fieldText.length;
207 return this.tt().getFfFieldMeta(this.fixedFieldCode)
210 this.maxLength = fieldMeta.length || 1;
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; }
220 switch (this.fieldType) {
222 return this.tagContextMenuEntries();
225 return this.tt().getSubfieldCodes(this.field.tag);
228 return this.tt().getSubfieldValues(
229 this.field.tag, this.subfield[0]);
233 return this.tt().getIndicatorValues(
234 this.field.tag, this.fieldType);
237 return this.tt().getFfValues(this.fixedFieldCode);
243 tagContextMenuEntries(): ContextMenuEntry[] {
245 // string components may not yet be loaded.
246 if (this.tagMenuEntries.length > 0 || !this.add006Str) {
247 return this.tagMenuEntries;
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'}
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'}
264 this.tagMenuEntries.push(
265 {label: this.deleteFieldStr.text, value: '_deleteField'},
269 this.tt().getFieldTags().forEach(e => this.tagMenuEntries.push(e));
271 return this.tagMenuEntries;
274 getContent(): string {
275 if (this.fieldText) { return this.fieldText; } // read-only
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;
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
295 if ( this.ffValue === undefined ||
296 !this.context.lastFocused ||
297 !this.focusRequestIsMe(this.context.lastFocused)) {
300 this.record.extractFixedField(this.fixedFieldCode);
307 setContent(value: string, propagatBigText?: boolean, skipUndoTrack?: boolean) {
309 if (this.fieldText) { return; } // read-only text
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;
320 // Track locally and propagate to the record.
321 this.ffValue = value;
322 this.record.setFixedField(this.fixedFieldCode, value);
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;
333 if (!skipUndoTrack) {
334 this.trackTextChangeForUndo(value);
338 trackTextChangeForUndo(value: string) {
340 // Human-driven changes invalidate the redo stack.
341 this.context.redoStack = [];
343 const lastUndo = this.context.undoStack[0];
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.
355 const undo = new TextUndoRedoAction();
356 undo.position = this.context.lastFocused;
357 undo.textContent = this.undoBackToText;
359 this.context.addToUndoStack(undo);
362 // Apply the undo or redo action and track its opposite
363 // action on the necessary stack
364 processUndoRedo(action: TextUndoRedoAction) {
366 // Undoing a text change
367 const recoverContent = this.getContent();
368 this.setContent(action.textContent, true, true);
370 action.textContent = recoverContent;
371 const moveTo = action.isRedo ?
372 this.context.undoStack : this.context.redoStack;
374 moveTo.unshift(action);
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();
384 // Propagate editable div content into our record
385 bigTextValueChange() {
386 this.setContent(this.editInput.innerText);
390 this.editInput = // numeric id requires [id=...] query selector
391 this.renderer.selectRootElement(`[id='${this.randId}']`);
393 // Initialize the editable div
394 this.editInput.innerText = this.getContent();
397 inputSize(): number {
398 if (this.maxLength) {
399 return this.maxLength + 1;
401 // give at least 2+ chars space and grow with the content
402 return Math.max(2, (this.getContent() || '').length) * 1.1;
406 const targetNode = this.editInput.firstChild;
409 // Div contains no text content, nothing to select
413 const range = document.createRange();
414 range.setStart(targetNode, 0);
415 range.setEnd(targetNode, targetNode.length);
417 const selection = window.getSelection();
418 selection.removeAllRanges();
419 selection.addRange(range);
422 // Route keydown events to the appropriate handler
423 inputKeyDown(evt: KeyboardEvent) {
427 if (evt.ctrlKey) { // redo
428 this.context.requestRedo();
429 evt.preventDefault();
434 if (evt.ctrlKey) { // undo
435 this.context.requestUndo();
436 evt.preventDefault();
442 // shift+F6 => add 006
443 this.context.add00X('006');
444 evt.preventDefault();
445 evt.stopPropagation();
451 // shift+F7 => add 007
452 this.context.add00X('007');
453 evt.preventDefault();
454 evt.stopPropagation();
460 // shift+F8 => add/replace 008
461 this.context.insertReplace008();
462 evt.preventDefault();
463 evt.stopPropagation();
468 // None of the remaining key combos are supported by the LDR
469 // or fixed field editor.
470 if (this.fieldType === 'ldr' || this.fieldType === 'ffld') { return; }
476 // ctrl+enter == insert stub field after focused field
477 // ctrl+shift+enter == insert stub field before focused field
478 this.context.insertStubField(this.field, evt.shiftKey);
481 evt.preventDefault(); // Bare newlines not allowed.
487 // ctrl+delete == delete whole field
488 this.context.deleteField(this.field);
489 evt.preventDefault();
491 } else if (evt.shiftKey) {
494 // shift+delete == delete subfield
496 this.context.deleteSubfield(this.field, this.subfield);
498 // prevent any shift-delete from bubbling up becuase
499 // unexpected stuff will be deleted.
500 evt.preventDefault();
508 // ctrl+down == copy current field down one
509 this.context.insertField(
510 this.field, this.record.cloneField(this.field));
512 // avoid dupe focus requests
513 this.context.focusNextTag(this.field);
516 evt.preventDefault();
522 // ctrl+up == copy current field up one
523 this.context.insertField(
524 this.field, this.record.cloneField(this.field), true);
526 // avoid dupe focus requests
527 this.context.focusPreviousTag(this.field);
530 // up == move focus to tag of previous field
531 evt.preventDefault();
537 // ctrl+i / ctrl+d == insert subfield
538 const pos = this.subfield ? this.subfield[2] + 1 : 0;
539 this.context.insertStubSubfield(this.field, pos);
540 evt.preventDefault();
546 insertField(before: boolean) {
548 const newField = this.record.newField(
549 {tag: '999', subfields: [[' ', '', 0]]});
552 this.record.insertFieldsBefore(this.field, newField);
554 this.record.insertFieldsAfter(this.field, newField);
557 this.context.requestFieldFocus(
558 {fieldId: newField.fieldId, target: 'tag'});
562 if (!this.context.focusNextTag(this.field)) {
563 this.context.focusPreviousTag(this.field);
566 this.record.deleteFields(this.field);
570 // If subfields remain, focus the previous subfield.
571 // otherwise focus our tag.
572 const sfpos = this.subfield[2] - 1;
574 this.field.deleteExactSubfields(this.subfield);
576 const focus: FieldFocusRequest = {
577 fieldId: this.field.fieldId, target: 'tag'};
580 focus.target = 'sfv';
581 focus.sfOffset = sfpos;
584 this.context.requestFieldFocus(focus);
587 contextMenuChange(value: string) {
590 case '_add006': return this.context.add00X('006');
591 case '_add007': return this.context.add00X('007');
592 case '_add008': return this.context.insertReplace008();
593 case '_insertBefore':
594 return this.context.insertStubField(this.field, true);
596 return this.context.insertStubField(this.field);
597 case '_deleteField': return this.context.deleteField(this.field);
600 this.setContent(value, true);
602 // Context menus can steal focus.
603 this.context.requestFieldFocus(this.context.lastFocused);
606 isAuthInvalid(): boolean {
608 this.fieldType === 'sfv' &&
609 this.field.authChecked &&
610 !this.field.authValid
614 isAuthValid(): boolean {
616 this.fieldType === 'sfv' &&
617 this.field.authChecked &&
622 isLastSubfieldValue(): boolean {
623 if (this.fieldType === 'sfv') {
624 const myIdx = this.subfield[2];
625 for (let idx = 0; idx < this.field.subfields.length; idx++) {