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