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