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';
12 * MARC Editable Content Component
16 selector: 'eg-marc-editable-content',
17 templateUrl: './editable-content.component.html',
18 styleUrls: ['./editable-content.component.css']
21 export class EditableContentComponent
22 implements OnInit, AfterViewInit, OnDestroy {
24 @Input() context: MarcEditContext;
25 @Input() field: MarcField;
26 @Input() fieldType: MARC_EDITABLE_FIELD_TYPE = null;
28 // read-only field text. E.g. 'LDR'
29 @Input() fieldText: string = null;
31 // array of subfield code and subfield value
32 @Input() subfield: MarcSubfield;
34 @Input() fixedFieldCode: string;
36 // space-separated list of additional CSS classes to append
37 @Input() moreClasses: string;
39 // aria-label text. This will not be visible in the UI.
40 @Input() ariaLabel: string;
42 get record(): MarcRecord { return this.context.record; }
45 randId = Math.floor(Math.random() * 100000);
46 editInput: any; // <input/> or <div contenteditable/>
47 maxLength: number = null;
49 // Track the load-time content so we know what text value to
50 // track on our undo stack.
51 undoBackToText: string;
53 focusSub: Subscription;
54 undoRedoSub: Subscription;
55 isLeader: boolean; // convenience
57 // Cache of fixed field menu options
58 ffValues: ContextMenuEntry[] = [];
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.
66 private renderer: Renderer2,
67 private tagTable: TagTableService) {}
70 this.setupFieldType();
74 if (this.focusSub) { this.focusSub.unsubscribe(); }
75 if (this.undoRedoSub) { this.undoRedoSub.unsubscribe(); }
78 watchForFocusRequests() {
79 this.focusSub = this.context.fieldFocusRequest.pipe(
80 filter((req: FieldFocusRequest) => this.focusRequestIsMe(req)))
81 .subscribe((req: FieldFocusRequest) => this.selectText(req));
84 watchForUndoRedoRequests() {
85 this.undoRedoSub = this.context.textUndoRedoRequest.pipe(
86 filter((action: TextUndoRedoAction) => this.focusRequestIsMe(action.position)))
87 .subscribe((action: TextUndoRedoAction) => this.processUndoRedo(action));
90 focusRequestIsMe(req: FieldFocusRequest): boolean {
91 if (req.target !== this.fieldType) { return false; }
94 if (req.fieldId !== this.field.fieldId) { return false; }
95 } else if (req.target === 'ldr') {
97 } else if (req.target === 'ffld' &&
98 req.ffCode !== this.fixedFieldCode) {
102 if (req.sfOffset !== undefined &&
103 req.sfOffset !== this.subfield[2]) {
104 // this is not the subfield you are looking for.
111 selectText(req?: FieldFocusRequest) {
115 this.editInput.select();
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.
123 fieldId: this.field ? this.field.fieldId : -1,
124 target: this.fieldType,
125 sfOffset: this.subfield ? this.subfield[2] : undefined,
126 ffCode: this.fixedFieldCode
130 this.context.lastFocused = req;
134 const content = this.getContent();
135 this.undoBackToText = content;
137 switch (this.fieldType) {
139 this.isLeader = true;
140 if (content) { this.maxLength = content.length; }
141 this.watchForFocusRequests();
142 this.watchForUndoRedoRequests();
147 this.watchForFocusRequests();
148 this.watchForUndoRedoRequests();
152 this.watchForFocusRequests();
153 this.watchForUndoRedoRequests();
157 this.applyFFOptions();
158 this.watchForFocusRequests();
159 this.watchForUndoRedoRequests();
165 this.watchForFocusRequests();
166 this.watchForUndoRedoRequests();
171 this.watchForFocusRequests();
172 this.watchForUndoRedoRequests();
177 this.watchForFocusRequests();
178 this.watchForUndoRedoRequests();
182 if (this.fieldText) {
183 this.maxLength = this.fieldText.length;
189 return this.tagTable.getFfFieldMeta(
190 this.fixedFieldCode, this.record.recordType())
193 this.maxLength = fieldMeta.length || 1;
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; }
203 switch (this.fieldType) {
205 return this.tagTable.getFieldTags();
208 return this.tagTable.getSubfieldCodes(this.field.tag);
211 return this.tagTable.getSubfieldValues(
212 this.field.tag, this.subfield[0]);
216 return this.tagTable.getIndicatorValues(
217 this.field.tag, this.fieldType);
220 return this.tagTable.getFfValues(
221 this.fixedFieldCode, this.record.recordType());
227 getContent(): string {
228 if (this.fieldText) { return this.fieldText; } // read-only
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;
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
248 if ( this.ffValue === undefined ||
249 !this.context.lastFocused ||
250 !this.focusRequestIsMe(this.context.lastFocused)) {
253 this.record.extractFixedField(this.fixedFieldCode);
260 setContent(value: string, propagatBigText?: boolean, skipUndoTrack?: boolean) {
262 if (this.fieldText) { return; } // read-only text
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;
273 // Track locally and propagate to the record.
274 this.ffValue = value;
275 this.record.setFixedField(this.fixedFieldCode, value);
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;
286 if (!skipUndoTrack) {
287 this.trackTextChangeForUndo(value);
291 trackTextChangeForUndo(value: string) {
293 // Human-driven changes invalidate the redo stack.
294 this.context.redoStack = [];
296 const lastUndo = this.context.undoStack[0];
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.
308 const undo = new TextUndoRedoAction();
309 undo.position = this.context.lastFocused;
310 undo.textContent = this.undoBackToText;
312 this.context.addToUndoStack(undo);
315 // Apply the undo or redo action and track its opposite
316 // action on the necessary stack
317 processUndoRedo(action: TextUndoRedoAction) {
319 // Undoing a text change
320 const recoverContent = this.getContent();
321 this.setContent(action.textContent, true, true);
323 action.textContent = recoverContent;
324 const moveTo = action.isRedo ?
325 this.context.undoStack : this.context.redoStack;
327 moveTo.unshift(action);
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();
337 // Propagate editable div content into our record
338 bigTextValueChange() {
339 this.setContent(this.editInput.innerText);
343 this.editInput = // numeric id requires [id=...] query selector
344 this.renderer.selectRootElement(`[id='${this.randId}']`);
346 // Initialize the editable div
347 this.editInput.innerText = this.getContent();
350 inputSize(): number {
351 if (this.maxLength) {
352 return this.maxLength + 1;
354 // give at least 2+ chars space and grow with the content
355 return Math.max(2, (this.getContent() || '').length) * 1.1;
359 const targetNode = this.editInput.firstChild;
362 // Div contains no text content, nothing to select
366 const range = document.createRange();
367 range.setStart(targetNode, 0);
368 range.setEnd(targetNode, targetNode.length);
370 const selection = window.getSelection();
371 selection.removeAllRanges();
372 selection.addRange(range);
375 // Route keydown events to the appropriate handler
376 inputKeyDown(evt: KeyboardEvent) {
380 if (evt.ctrlKey) { // redo
381 this.context.requestRedo();
382 evt.preventDefault();
387 if (evt.ctrlKey) { // undo
388 this.context.requestUndo();
389 evt.preventDefault();
395 // shift+F6 => add 006
396 this.context.add00X('006');
397 evt.preventDefault();
398 evt.stopPropagation();
404 // shift+F7 => add 007
405 this.context.add00X('007');
406 evt.preventDefault();
407 evt.stopPropagation();
413 // shift+F8 => add/replace 008
414 this.context.insertReplace008();
415 evt.preventDefault();
416 evt.stopPropagation();
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; }
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);
434 evt.preventDefault(); // Bare newlines not allowed.
440 // ctrl+delete == delete whole field
441 this.context.deleteField(this.field);
442 evt.preventDefault();
444 } else if (evt.shiftKey) {
447 // shift+delete == delete subfield
449 this.context.deleteSubfield(this.field, this.subfield);
451 // prevent any shift-delete from bubbling up becuase
452 // unexpected stuff will be deleted.
453 evt.preventDefault();
461 // ctrl+down == copy current field down one
462 this.context.insertField(
463 this.field, this.record.cloneField(this.field));
465 // avoid dupe focus requests
466 this.context.focusNextTag(this.field);
469 evt.preventDefault();
475 // ctrl+up == copy current field up one
476 this.context.insertField(
477 this.field, this.record.cloneField(this.field), true);
479 // avoid dupe focus requests
480 this.context.focusPreviousTag(this.field);
483 // up == move focus to tag of previous field
484 evt.preventDefault();
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();
499 insertField(before: boolean) {
501 const newField = this.record.newField(
502 {tag: '999', subfields: [[' ', '', 0]]});
505 this.record.insertFieldsBefore(this.field, newField);
507 this.record.insertFieldsAfter(this.field, newField);
510 this.context.requestFieldFocus(
511 {fieldId: newField.fieldId, target: 'tag'});
515 if (!this.context.focusNextTag(this.field)) {
516 this.context.focusPreviousTag(this.field);
519 this.record.deleteFields(this.field);
523 // If subfields remain, focus the previous subfield.
524 // otherwise focus our tag.
525 const sfpos = this.subfield[2] - 1;
527 this.field.deleteExactSubfields(this.subfield);
529 const focus: FieldFocusRequest = {
530 fieldId: this.field.fieldId, target: 'tag'};
533 focus.target = 'sfv';
534 focus.sfOffset = sfpos;
537 this.context.requestFieldFocus(focus);
540 contextMenuChange(value: string) {
541 this.setContent(value, true);
543 // Context menus can steal focus.
544 this.context.requestFieldFocus(this.context.lastFocused);