]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
LP 2061136 follow-up: ng lint --fix
[Evergreen.git] / Open-ILS / src / eg2 / src / app / staff / share / marc-edit / editor-context.ts
1 import {EventEmitter} from '@angular/core';
2 import {MarcRecord, MarcField, MarcSubfield} from './marcrecord';
3 import {NgbPopover} from '@ng-bootstrap/ng-bootstrap';
4 import {TagTable} from './tagtable.service';
5
6 const MARC_RECORD_TYPES: 'biblio' | 'authority' | 'serial' | 'lineitem' = null;
7
8 export type MARC_RECORD_TYPE = typeof MARC_RECORD_TYPES;
9
10 /* Per-instance MARC editor context. */
11
12 const STUB_DATA_00X = '                                        ';
13
14 export type MARC_EDITABLE_FIELD_TYPE =
15     'ldr' | 'tag' | 'cfld' | 'ind1' | 'ind2' | 'sfc' | 'sfv' | 'ffld';
16
17 export interface FieldFocusRequest {
18     fieldId: number;
19     target: MARC_EDITABLE_FIELD_TYPE;
20     sfOffset?: number; // focus a specific subfield by its offset
21     ffCode?: string; // fixed field code
22
23     // If set, an external source wants to modify the text content
24     // of an editable component (in a way that retains undo/redo
25     // functionality).
26     newText?: string;
27 }
28
29 export class UndoRedoAction {
30     // Which point in the record was modified.
31     position: FieldFocusRequest;
32
33     // Which stack do we toss this on once it's been applied?
34     isRedo: boolean;
35
36     // Grouped actions are tracked as multiple undo / redo actions, but
37     // are done and un-done as a unit.
38     groupSize?: number;
39 }
40
41 export class TextUndoRedoAction extends UndoRedoAction {
42     textContent: string;
43 }
44
45 export class StructUndoRedoAction extends UndoRedoAction {
46     /* Add or remove a part of the record (field, subfield, etc.) */
47
48     // Does this action track an addition or deletion.
49     wasAddition: boolean;
50
51     // Field to add/delete or field to modify for subfield adds/deletes
52     field: MarcField;
53
54     // If this is a subfield modification.
55     subfield: MarcSubfield;
56
57     // Position preceding the modified position to mark the position
58     // of deletion recovery.
59     prevPosition: FieldFocusRequest;
60
61     // Location of the cursor at time of initial action.
62     prevFocus: FieldFocusRequest;
63 }
64
65
66 export class MarcEditContext {
67
68     recordChange: EventEmitter<MarcRecord>;
69     fieldFocusRequest: EventEmitter<FieldFocusRequest>;
70     textUndoRedoRequest: EventEmitter<TextUndoRedoAction>;
71     recordType: MARC_RECORD_TYPE;
72
73     lastFocused: FieldFocusRequest = null;
74
75     undoStack: UndoRedoAction[] = [];
76     redoStack: UndoRedoAction[] = [];
77
78     tagTable: TagTable;
79
80     // True if any changes have been made.
81     // For the 'rich' editor, this is any un-do-able actions.
82     // For the text edtior it's any text change.
83     changesPending: boolean;
84
85     private _record: MarcRecord;
86     set record(r: MarcRecord) {
87         if (r !== this._record) {
88             this._record = r;
89             this._record.stampFieldIds();
90             this.recordChange.emit(r);
91         }
92     }
93
94     get record(): MarcRecord {
95         return this._record;
96     }
97
98     constructor() {
99         this.recordChange = new EventEmitter<MarcRecord>();
100         this.fieldFocusRequest = new EventEmitter<FieldFocusRequest>();
101         this.textUndoRedoRequest = new EventEmitter<TextUndoRedoAction>();
102     }
103
104     requestFieldFocus(req: FieldFocusRequest) {
105         // timeout allows for new components to be built before the
106         // focus request is emitted.
107         if (req) {
108             setTimeout(() => this.fieldFocusRequest.emit(req));
109         }
110     }
111
112     resetUndos() {
113         this.undoStack = [];
114         this.redoStack = [];
115     }
116
117     requestUndo() {
118         let remaining = null;
119
120         do {
121             const action = this.undoStack.shift();
122             if (!action) { return; }
123
124             if (remaining === null) {
125                 remaining = action.groupSize || 1;
126             }
127             remaining--;
128
129             action.isRedo = false;
130             this.distributeUndoRedo(action);
131
132         } while (remaining > 0);
133     }
134
135     requestRedo() {
136         let remaining = null;
137
138         do {
139             const action = this.redoStack.shift();
140             if (!action) { return; }
141
142             if (remaining === null) {
143                 remaining = action.groupSize || 1;
144             }
145             remaining--;
146
147             action.isRedo = true;
148             this.distributeUndoRedo(action);
149
150         } while (remaining > 0);
151     }
152
153     // Calculate stack action count taking groupSize (atomic action
154     // sets) into consideration.
155     stackCount(stack: UndoRedoAction[]): number {
156         let size = 0;
157         let skip = 0;
158
159         stack.forEach(action => {
160             if (action.groupSize > 1) {
161                 if (skip) { return; }
162                 skip = 1;
163             } else {
164                 skip = 0;
165             }
166             size++;
167         });
168
169         return size;
170     }
171
172     undoCount(): number {
173         return this.stackCount(this.undoStack);
174     }
175
176     redoCount(): number {
177         return this.stackCount(this.redoStack);
178     }
179
180     // Stamp the most recent 'size' entries in the undo stack
181     // as being an atomic undo/redo set.
182     setUndoGroupSize(size: number) {
183         for (let idx = 0; idx < size; idx++) {
184             if (this.undoStack[idx]) {
185                 this.undoStack[idx].groupSize = size;
186             }
187         }
188     }
189
190     distributeUndoRedo(action: UndoRedoAction) {
191         if (action instanceof TextUndoRedoAction) {
192             // Let the editable content component handle it.
193             this.textUndoRedoRequest.emit(action);
194         } else {
195             // Manage structural changes within
196             this.handleStructuralUndoRedo(action as StructUndoRedoAction);
197         }
198     }
199
200     addToUndoStack(action: UndoRedoAction) {
201         this.changesPending = true;
202         this.undoStack.unshift(action);
203     }
204
205     handleStructuralUndoRedo(action: StructUndoRedoAction) {
206
207         if (action.wasAddition) {
208             // Remove the added field
209
210             if (action.subfield) {
211                 const prevPos = action.subfield[2] - 1;
212                 action.field.deleteExactSubfields(action.subfield);
213                 this.focusSubfield(action.field, prevPos);
214
215             } else {
216                 this.record.deleteFields(action.field);
217             }
218
219             // When deleting chunks, always return focus to the
220             // pre-insert position.
221             this.requestFieldFocus(action.prevFocus);
222
223         } else {
224             // Re-insert the removed field and focus it.
225
226             if (action.subfield) {
227
228                 this.insertSubfield(action.field, action.subfield, true);
229                 this.focusSubfield(action.field, action.subfield[2]);
230
231             } else {
232
233                 const fieldId = action.position.fieldId;
234                 const prevField =
235                     this.record.getField(action.prevPosition.fieldId);
236
237                 this.record.insertFieldsAfter(prevField, action.field);
238
239                 // Recover the original fieldId, which gets re-stamped
240                 // in this.record.insertFields* calls.
241                 action.field.fieldId = fieldId;
242
243                 // Focus the newly recovered field.
244                 this.requestFieldFocus(action.position);
245             }
246
247             // When inserting chunks, track the location where the
248             // insert was requested so we can return the cursor so we
249             // can return the cursor to the scene of the crime if the
250             // undo is re-done or vice versa.  This is primarily useful
251             // when performing global inserts like add00X, which can be
252             // done without the 00X field itself having focus.
253             action.prevFocus = this.lastFocused;
254         }
255
256         action.wasAddition = !action.wasAddition;
257
258         const moveTo = action.isRedo ? this.undoStack : this.redoStack;
259
260         moveTo.unshift(action);
261     }
262
263     trackStructuralUndo(field: MarcField, isAddition: boolean, subfield?: MarcSubfield) {
264
265         // Human-driven changes invalidate the redo stack.
266         this.redoStack = [];
267
268         const position: FieldFocusRequest = {fieldId: field.fieldId, target: 'tag'};
269
270         let prevPos: FieldFocusRequest = null;
271
272         if (subfield) {
273             position.target = 'sfc';
274             position.sfOffset = subfield[2];
275
276         } else {
277             // No need to track the previous field for subfield mods.
278
279             const prevField = this.record.getPreviousField(field.fieldId);
280             if (prevField) {
281                 prevPos = {fieldId: prevField.fieldId, target: 'tag'};
282             }
283         }
284
285         const action = new StructUndoRedoAction();
286         action.field = field;
287         action.subfield = subfield;
288         action.wasAddition = isAddition;
289         action.position = position;
290         action.prevPosition = prevPos;
291
292         // For bulk adds (e.g. add a whole row) the field focused at
293         // time of action will be different than the added field.
294         action.prevFocus = this.lastFocused;
295
296         this.addToUndoStack(action);
297     }
298
299     deleteField(field: MarcField) {
300         this.trackStructuralUndo(field, false);
301
302         if (!this.focusNextTag(field)) {
303             this.focusPreviousTag(field);
304         }
305
306         this.record.deleteFields(field);
307     }
308
309     add00X(tag: string) {
310
311         const field: MarcField =
312             this.record.newField({tag : tag, data : STUB_DATA_00X});
313
314         this.record.insertOrderedFields(field);
315
316         this.trackStructuralUndo(field, true);
317
318         this.focusTag(field);
319     }
320
321     insertReplace008() {
322
323         // delete all of the 008s
324         [].concat(this.record.field('008', true)).forEach(f => {
325             this.trackStructuralUndo(f, false);
326             this.record.deleteFields(f);
327         });
328
329         const field = this.record.newField({
330             tag : '008', data : this.record.generate008()});
331
332         this.record.insertOrderedFields(field);
333
334         this.trackStructuralUndo(field, true);
335
336         this.focusTag(field);
337     }
338
339     // Add stub field before or after the context field
340     insertStubField(field: MarcField, before?: boolean) {
341
342         const newField = this.record.newField(
343             {tag: '999', subfields: [[' ', '', 0]]});
344
345         this.insertField(field, newField, before);
346     }
347
348     insertField(contextField: MarcField, newField: MarcField, before?: boolean) {
349
350         if (before) {
351             this.record.insertFieldsBefore(contextField, newField);
352             this.focusPreviousTag(contextField);
353
354         } else {
355             this.record.insertFieldsAfter(contextField, newField);
356             this.focusNextTag(contextField);
357         }
358
359         this.trackStructuralUndo(newField, true);
360     }
361
362     // Adds a new empty subfield to the provided field at the
363     // requested subfield position
364     insertSubfield(field: MarcField,
365         subfield: MarcSubfield, skipTracking?: boolean) {
366         const position = subfield[2];
367
368         // array index 3 contains that position of the subfield
369         // in the MARC field.  When splicing a new subfield into
370         // the set, be sure the any that come after the new one
371         // have their positions bumped to reflect the shift.
372         field.subfields.forEach(
373             sf => {if (sf[2] >= position) { sf[2]++; }});
374
375         field.subfields.splice(position, 0, subfield);
376
377         if (!skipTracking) {
378             this.focusSubfield(field, position);
379             this.trackStructuralUndo(field, true, subfield);
380         }
381     }
382
383     insertStubSubfield(field: MarcField, position: number) {
384         const newSf: MarcSubfield = [' ', '', position];
385         this.insertSubfield(field, newSf);
386     }
387
388     // Focus the requested subfield by its position.  If its
389     // position is less than zero, focus the field's tag instead.
390     focusSubfield(field: MarcField, position: number) {
391
392         const focus: FieldFocusRequest = {fieldId: field.fieldId, target: 'tag'};
393
394         if (position >= 0) {
395             // Focus the code instead of the value, because attempting to
396             // focus an empty (editable) div results in nothing getting focus.
397             focus.target = 'sfc';
398             focus.sfOffset = position;
399         }
400
401         this.requestFieldFocus(focus);
402     }
403
404     deleteSubfield(field: MarcField, subfield: MarcSubfield) {
405         const sfpos = subfield[2] - 1; // previous subfield
406
407         this.trackStructuralUndo(field, false, subfield);
408
409         field.deleteExactSubfields(subfield);
410
411         this.focusSubfield(field, sfpos);
412     }
413
414     focusTag(field: MarcField) {
415         this.requestFieldFocus({fieldId: field.fieldId, target: 'tag'});
416     }
417
418     // Returns true if the field has a next tag to focus
419     focusNextTag(field: MarcField) {
420         const nextField = this.record.getNextField(field.fieldId);
421         if (nextField) {
422             this.focusTag(nextField);
423             return true;
424         }
425         return false;
426     }
427
428     // Returns true if the field has a previous tag to focus
429     focusPreviousTag(field: MarcField): boolean {
430         const prevField = this.record.getPreviousField(field.fieldId);
431         if (prevField) {
432             this.focusTag(prevField);
433             return true;
434         }
435         return false;
436     }
437 }
438