From 086f54a1e8d56bc7fc8f649b44eddae6dae12e7c Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Thu, 14 Nov 2019 16:54:21 -0500 Subject: [PATCH] LP1852782 Angular MARC enriched editor (first batch) Main rich MARC editor component. Includes fixed fields editor, context menus for value selection, undo/redo, help display, keyboard shortcuts. Also includes a standalone context menu component. Signed-off-by: Bill Erickson Signed-off-by: Jane Sandberg --- .../src/app/share/combobox/combobox.component.ts | 2 + .../src/eg2/src/app/share/common-widgets.module.ts | 9 +- .../context-menu-container.component.css | 27 ++ .../context-menu-container.component.html | 8 + .../context-menu-container.component.ts | 38 ++ .../share/context-menu/context-menu.directive.ts | 90 ++++ .../app/share/context-menu/context-menu.module.ts | 24 + .../app/share/context-menu/context-menu.service.ts | 32 ++ .../share/marc-edit/editable-content.component.css | 22 + .../marc-edit/editable-content.component.html | 35 ++ .../share/marc-edit/editable-content.component.ts | 534 +++++++++++++++++++++ .../app/staff/share/marc-edit/editor-context.ts | 351 ++++++++++++++ .../staff/share/marc-edit/editor.component.html | 30 +- .../app/staff/share/marc-edit/editor.component.ts | 44 +- .../share/marc-edit/fixed-field.component.css | 20 + .../share/marc-edit/fixed-field.component.html | 16 + .../staff/share/marc-edit/fixed-field.component.ts | 46 ++ .../marc-edit/fixed-fields-editor.component.html | 281 +++++++++++ .../marc-edit/fixed-fields-editor.component.ts | 31 ++ .../staff/share/marc-edit/flat-editor.component.ts | 19 +- .../app/staff/share/marc-edit/marc-edit.module.ts | 14 +- .../src/app/staff/share/marc-edit/marcrecord.ts | 147 +++++- .../share/marc-edit/rich-editor.component.css | 8 + .../share/marc-edit/rich-editor.component.html | 139 ++++++ .../staff/share/marc-edit/rich-editor.component.ts | 57 ++- .../app/staff/share/marc-edit/tagtable.service.ts | 274 +++++++++++ .../src/eg2/src/app/staff/staff.component.html | 2 + Open-ILS/src/eg2/src/styles.css | 1 + 28 files changed, 2259 insertions(+), 42 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.css create mode 100644 Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts create mode 100644 Open-ILS/src/eg2/src/app/share/context-menu/context-menu.module.ts create mode 100644 Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts diff --git a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts index 3d9860471f..02839579d3 100644 --- a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts +++ b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts @@ -52,6 +52,8 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit { @Input() allowFreeText = false; + @Input() inputSize: number = null; + // Add a 'required' attribute to the input isRequired: boolean; @Input() set required(r: boolean) { diff --git a/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts b/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts index 01b16bd4da..e1f85cdd3e 100644 --- a/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts +++ b/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts @@ -14,6 +14,7 @@ import {DateSelectComponent} from '@eg/share/date-select/date-select.component'; import {OrgSelectComponent} from '@eg/share/org-select/org-select.component'; import {DateRangeSelectComponent} from '@eg/share/daterange-select/daterange-select.component'; import {DateTimeSelectComponent} from '@eg/share/datetime-select/datetime-select.component'; +import {ContextMenuModule} from '@eg/share/context-menu/context-menu.module'; @NgModule({ @@ -23,14 +24,15 @@ import {DateTimeSelectComponent} from '@eg/share/datetime-select/datetime-select DateSelectComponent, OrgSelectComponent, DateRangeSelectComponent, - DateTimeSelectComponent, + DateTimeSelectComponent ], imports: [ CommonModule, FormsModule, ReactiveFormsModule, NgbModule, - EgCoreModule + EgCoreModule, + ContextMenuModule ], exports: [ CommonModule, @@ -43,7 +45,8 @@ import {DateTimeSelectComponent} from '@eg/share/datetime-select/datetime-select OrgSelectComponent, DateRangeSelectComponent, DateTimeSelectComponent, - ], + ContextMenuModule + ] }) export class CommonWidgetsModule { } diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.css b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.css new file mode 100644 index 0000000000..3323d2a3e7 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.css @@ -0,0 +1,27 @@ + +.eg-context-menu { + /* These fonts were applied specifically for the MARC editor + * context menus. Might want to make these optional. */ + font-family: 'Lucida Console', Monaco, monospace; + + /* put a hard limit on the popover width */ + max-width: 550px; +} + +.eg-context-menu .popover-body { + max-height: 400px; + + /* Text exceeding the max-height / max-width will results in scrolls. + * In most cases, this should not happen. */ + overflow-y: auto; + overflow-x: auto; +} + +.eg-context-menu .popover-body .menu-entry { + /* force the menu to expand horizontally to display the text */ + white-space: nowrap; +} + +.eg-context-menu .popover-body .menu-entry:hover { + background-color: #f8f9fa; +} diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html new file mode 100644 index 0000000000..0d6c0a0ede --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html @@ -0,0 +1,8 @@ + + + +
+ {{entry.label}} +
+
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts new file mode 100644 index 0000000000..a5dfdcefbf --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts @@ -0,0 +1,38 @@ +import {Component, Input, Output, EventEmitter, OnInit, ViewChild, + AfterViewInit, TemplateRef, ViewEncapsulation} from '@angular/core'; +import {ContextMenuService, ContextMenu, ContextMenuEntry} from './context-menu.service'; + +@Component({ + selector: 'eg-context-menu-container', + templateUrl: './context-menu-container.component.html', + styleUrls: ['context-menu-container.component.css'], + /* Our CSS affects the style of the popover, which may + * be beyond our reach for standard view encapsulation */ + encapsulation: ViewEncapsulation.None +}) + +export class ContextMenuContainerComponent implements OnInit, AfterViewInit { + + menuEntries: ContextMenuEntry[] = []; + @ViewChild('menuTemplate', {static: false}) menuTemplate: TemplateRef; + + constructor(private menuService: ContextMenuService) {} + + ngOnInit() { + + this.menuService.showMenuRequest.subscribe( + (menu: ContextMenu) => { + + this.menuEntries = menu.entries + }); + } + + ngAfterViewInit() { + this.menuService.menuTemplate = this.menuTemplate; + } + + entryClicked(entry: ContextMenuEntry) { + this.menuService.menuItemSelected.emit(entry); + } +} + diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts new file mode 100644 index 0000000000..591d9d01f3 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts @@ -0,0 +1,90 @@ +import {Input, Output, EventEmitter, Directive} from '@angular/core'; +import {NgbPopover} from '@ng-bootstrap/ng-bootstrap'; +import {ContextMenuService, ContextMenu, ContextMenuEntry} from './context-menu.service'; + + +/* Import all of this stuff so we can pass it to our parent + * class via its constructor */ +import { + Inject, Injector, Renderer2, ElementRef, TemplateRef, ViewContainerRef, + ComponentFactoryResolver, NgZone, ChangeDetectorRef, ApplicationRef +} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; +import {NgbPopoverConfig} from '@ng-bootstrap/ng-bootstrap'; +/* --- */ + +@Directive({ + selector: '[egContextMenu]', + exportAs: 'egContextMenu' +}) +export class ContextMenuDirective extends NgbPopover { + + triggers = 'contextmenu'; + popoverClass = 'eg-context-menu'; + + menuEntries: ContextMenuEntry[] = []; + menu: ContextMenu; + + @Input() set egContextMenu(menuEntries: ContextMenuEntry[]) { + this.menuEntries = menuEntries; + } + + @Output() menuItemSelected: EventEmitter; + + // Only one active menu is allowed at a time. + static activeDirective: ContextMenuDirective; + static menuId = 0; + + constructor( + p1: ElementRef, p2: Renderer2, p3: Injector, + p4: ComponentFactoryResolver, p5: ViewContainerRef, p6: NgbPopoverConfig, + p7: NgZone, @Inject(DOCUMENT) p8: any, p9: ChangeDetectorRef, + p10: ApplicationRef, private menuService: ContextMenuService) { + + // relay injected services to parent + super(p1, p2, p3, p4, p5, p6, p7, p8, p9, p10); + + this.menuItemSelected = new EventEmitter(); + + this.menuService.menuItemSelected.subscribe( + (entry: ContextMenuEntry) => { + + // Only broadcast entry selection to my listeners if I'm + // hosting the menu where the selection occurred. + + if (this.menu && this.menu.id === this.menuService.activeMenu.id) { + this.menuItemSelected.emit(entry); + } + }); + } + + open() { + + // In certain scenarios (e.g. right-clicking on another context + // menu) an open popover will stay open. Force it closed here. + if (ContextMenuDirective.activeDirective) { + ContextMenuDirective.activeDirective.close(); + ContextMenuDirective.activeDirective = null; + this.menuService.activeMenu == null; + } + + if (!this.menuEntries || + this.menuEntries.length === 0) { + return; + } + + this.menu = new ContextMenu(); + this.menu.id = ContextMenuDirective.menuId++; + this.menu.entries = this.menuEntries; + + this.menuService.activeMenu = this.menu; + this.menuService.showMenuRequest.emit(this.menu); + this.ngbPopover = this.menuService.menuTemplate; + + ContextMenuDirective.activeDirective = this; + + super.open(); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.module.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.module.ts new file mode 100644 index 0000000000..fb25e6144a --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.module.ts @@ -0,0 +1,24 @@ +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; +import {ContextMenuService} from './context-menu.service'; +import {ContextMenuDirective} from './context-menu.directive'; +import {ContextMenuContainerComponent} from './context-menu-container.component'; + +@NgModule({ + declarations: [ + ContextMenuDirective, + ContextMenuContainerComponent + ], + imports: [ + CommonModule, + NgbModule + ], + exports: [ + ContextMenuDirective, + ContextMenuContainerComponent + ] +}) + +export class ContextMenuModule { } + diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts new file mode 100644 index 0000000000..bfedf4232f --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts @@ -0,0 +1,32 @@ +import {Injectable, EventEmitter, TemplateRef} from '@angular/core'; +import {tap} from 'rxjs/operators'; + +/* Relay requests to/from the context menu directive and its + * template container component */ + +export interface ContextMenuEntry { + value: string; + label: string; +} + +export class ContextMenu { + id: number; + entries: ContextMenuEntry[]; +} + +@Injectable({providedIn: 'root'}) +export class ContextMenuService { + + showMenuRequest: EventEmitter; + menuItemSelected: EventEmitter; + + menuTemplate: TemplateRef; + activeMenu: ContextMenu; + + constructor() { + this.showMenuRequest = new EventEmitter(); + this.menuItemSelected = new EventEmitter(); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css new file mode 100644 index 0000000000..e21bb843d8 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css @@ -0,0 +1,22 @@ + +div[contenteditable] { + /* provide plenty of input space */ + min-width: 2em; + /* match BS form-control border color */ + border: 1px solid rgb(206, 212, 218); + /* match BS form-control input height */ + min-height: calc(1.5em + .75rem + 2px); +} + +.sf-delimiter { + /* match angjs color */ + color: rgb(0, 0, 255)!important; + /* snuggle up to my subfield code */ + margin-right: -0.5rem; +} + +.sf-code { + /* match angjs color */ + color: rgb(0, 0, 255)!important; +} + diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html new file mode 100644 index 0000000000..359128cee8 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html @@ -0,0 +1,35 @@ + + +
+
+
+ + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts new file mode 100644 index 0000000000..5f4b1ea8ab --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts @@ -0,0 +1,534 @@ +import {ElementRef, Component, Input, Output, OnInit, OnDestroy, + EventEmitter, AfterViewInit, Renderer2} from '@angular/core'; +import {Subscription} from 'rxjs'; +import {filter} from 'rxjs/operators'; +import {MarcRecord, MarcField, MarcSubfield} from './marcrecord'; +import {MarcEditContext, FieldFocusRequest, MARC_EDITABLE_FIELD_TYPE, + TextUndoRedoAction} from './editor-context'; +import {ContextMenuEntry} from '@eg/share/context-menu/context-menu.service'; +import {TagTableService} from './tagtable.service'; + +/** + * MARC Editable Content Component + */ + +@Component({ + selector: 'eg-marc-editable-content', + templateUrl: './editable-content.component.html', + styleUrls: ['./editable-content.component.css'] +}) + +export class EditableContentComponent + implements OnInit, AfterViewInit, OnDestroy { + + @Input() context: MarcEditContext; + @Input() field: MarcField; + @Input() fieldType: MARC_EDITABLE_FIELD_TYPE = null; + + // read-only field text. E.g. 'LDR' + @Input() fieldText: string = null; + + // array of subfield code and subfield value + @Input() subfield: MarcSubfield; + + @Input() fixedFieldCode: string; + + // space-separated list of additional CSS classes to append + @Input() moreClasses: string; + + get record(): MarcRecord { return this.context.record; } + + bigText = false; + randId = Math.floor(Math.random() * 100000); + editInput: any; // or
+ maxLength: number = null; + + // Track the load-time content so we know what text value to + // track on our undo stack. + undoBackToText: string; + + focusSub: Subscription; + undoRedoSub: Subscription; + isLeader: boolean; // convenience + + // Cache of fixed field menu options + ffValues: ContextMenuEntry[] = []; + + // Track the fixed field value locally since extracting the value + // in real time from the record, which adds padding to the text, + // causes usability problems. + ffValue: string; + + constructor( + private renderer: Renderer2, + private tagTable: TagTableService) {} + + ngOnInit() { + this.setupFieldType(); + } + + ngOnDestroy() { + if (this.focusSub) { this.focusSub.unsubscribe(); } + if (this.undoRedoSub) { this.undoRedoSub.unsubscribe(); } + } + + watchForFocusRequests() { + this.focusSub = this.context.fieldFocusRequest.pipe( + filter((req: FieldFocusRequest) => this.focusRequestIsMe(req))) + .subscribe((req: FieldFocusRequest) => this.selectText(req)); + } + + watchForUndoRedoRequests() { + this.undoRedoSub = this.context.textUndoRedoRequest.pipe( + filter((action: TextUndoRedoAction) => this.focusRequestIsMe(action.position))) + .subscribe((action: TextUndoRedoAction) => this.processUndoRedo(action)); + } + + focusRequestIsMe(req: FieldFocusRequest): boolean { + if (req.target !== this.fieldType) { return false; } + + if (this.field) { + if (req.fieldId !== this.field.fieldId) { return false; } + } else if (req.target === 'ldr') { + return this.isLeader; + } else if (req.target === 'ffld' && + req.ffCode !== this.fixedFieldCode) { + return false; + } + + if (req.sfOffset !== undefined && + req.sfOffset !== this.subfield[2]) { + // this is not the subfield you are looking for. + return false; + } + + return true; + } + + selectText(req?: FieldFocusRequest) { + if (this.bigText) { + this.focusBigText(); + } else { + this.editInput.select(); + } + + if (!req) { + // Focus request may have come from keyboard navigation, + // clicking, etc. Model the event as a focus request + // so it can be tracked the same. + req = { + fieldId: this.field ? this.field.fieldId : -1, + target: this.fieldType, + sfOffset: this.subfield ? this.subfield[2] : undefined, + ffCode: this.fixedFieldCode + }; + } + + this.context.lastFocused = req; + } + + setupFieldType() { + const content = this.getContent(); + this.undoBackToText = content; + + switch (this.fieldType) { + case 'ldr': + this.isLeader = true; + if (content) { this.maxLength = content.length; } + this.watchForFocusRequests(); + this.watchForUndoRedoRequests(); + break; + + case 'tag': + this.maxLength = 3; + this.watchForFocusRequests(); + this.watchForUndoRedoRequests(); + break; + + case 'cfld': + this.watchForFocusRequests(); + this.watchForUndoRedoRequests(); + break; + + case 'ffld': + this.applyFFOptions(); + this.watchForFocusRequests(); + this.watchForUndoRedoRequests(); + break; + + case 'ind1': + case 'ind2': + this.maxLength = 1; + this.watchForFocusRequests(); + this.watchForUndoRedoRequests(); + break; + + case 'sfc': + this.maxLength = 1; + this.watchForFocusRequests(); + this.watchForUndoRedoRequests(); + break; + + case 'sfv': + this.bigText = true; + this.watchForFocusRequests(); + this.watchForUndoRedoRequests(); + break; + } + } + + applyFFOptions() { + return this.tagTable.getFfFieldMeta( + this.fixedFieldCode, this.record.recordType()) + .then(fieldMeta => { + if (fieldMeta) { + this.maxLength = fieldMeta.length || 1; + } + }); + + // Fixed field options change when the record type changes. + this.context.recordChange.subscribe(_ => this.applyFFOptions()); + } + + // These are served dynamically to handle cases where a tag or + // subfield is modified in place. + contextMenuEntries(): ContextMenuEntry[] { + if (this.isLeader) { return; } + + switch(this.fieldType) { + case 'tag': + return this.tagTable.getFieldTags(); + + case 'sfc': + return this.tagTable.getSubfieldCodes(this.field.tag); + + case 'sfv': + return this.tagTable.getSubfieldValues( + this.field.tag, this.subfield[0]); + + case 'ind1': + case 'ind2': + return this.tagTable.getIndicatorValues( + this.field.tag, this.fieldType); + + case 'ffld': + return this.tagTable.getFfValues( + this.fixedFieldCode, this.record.recordType()); + } + + return null; + } + + getContent(): string { + if (this.fieldText) { return this.fieldText; } // read-only + + switch (this.fieldType) { + case 'ldr': return this.record.leader; + case 'cfld': return this.field.data; + case 'tag': return this.field.tag; + case 'sfc': return this.subfield[0]; + case 'sfv': return this.subfield[1]; + case 'ind1': return this.field.ind1; + case 'ind2': return this.field.ind2; + + case 'ffld': + // When actively editing a fixed field, track its value + // in a local variable instead of pulling the value + // from record.extractFixedField(), which applies + // additional formattting, causing usability problems + // (e.g. unexpected spaces). Once focus is gone, the + // view will be updated with the correctly formatted + // value. + + if ( this.ffValue === undefined || + !this.context.lastFocused || + !this.focusRequestIsMe(this.context.lastFocused)) { + + this.ffValue = + this.record.extractFixedField(this.fixedFieldCode); + } + return this.ffValue; + } + return 'X'; + } + + setContent(value: string, propagatBigText?: boolean, skipUndoTrack?: boolean) { + + if (this.fieldText) { return; } // read-only text + + switch (this.fieldType) { + case 'ldr': this.record.leader = value; break; + case 'cfld': this.field.data = value; break; + case 'tag': this.field.tag = value; break; + case 'sfc': this.subfield[0] = value; break; + case 'sfv': this.subfield[1] = value; break; + case 'ind1': this.field.ind1 = value; break; + case 'ind2': this.field.ind2 = value; break; + case 'ffld': + // Track locally and propagate to the record. + this.ffValue = value; + this.record.setFixedField(this.fixedFieldCode, value); + break; + } + + if (propagatBigText && this.bigText) { + // Propagate new content to the bigtext div. + // Should only be used when a content change occurrs via + // external means (i.e. not from a direct edit of the div). + this.editInput.innerText = value; + } + + if (!skipUndoTrack) { + this.trackTextChangeForUndo(value); + } + } + + trackTextChangeForUndo(value: string) { + + // Human-driven changes invalidate the redo stack. + this.context.redoStack = []; + + const lastUndo = this.context.undoStack[0]; + + if (lastUndo + && lastUndo instanceof TextUndoRedoAction + && lastUndo.textContent === this.undoBackToText + && this.focusRequestIsMe(lastUndo.position)) { + // Most recent undo entry was a text change event within the + // current atomic editing (focused) session for the input. + // Nothing else to track. + return; + } + + const undo = new TextUndoRedoAction(); + undo.position = this.context.lastFocused; + undo.textContent = this.undoBackToText; + + this.context.undoStack.unshift(undo); + } + + // Apply the undo or redo action and track its opposite + // action on the necessary stack + processUndoRedo(action: TextUndoRedoAction) { + + // Undoing a text change + const recoverContent = this.getContent(); + this.setContent(action.textContent, true, true); + + action.textContent = recoverContent; + const moveTo = action.isRedo ? + this.context.undoStack : this.context.redoStack; + + moveTo.unshift(action); + } + + inputBlurred() { + // If the text content changed during this focus session, + // track the new value as the value the next session of + // text edits should return to upon undo. + this.undoBackToText = this.getContent(); + } + + // Propagate editable div content into our record + bigTextValueChange() { + this.setContent(this.editInput.innerText); + } + + ngAfterViewInit() { + this.editInput = // numeric id requires [id=...] query selector + this.renderer.selectRootElement(`[id='${this.randId}']`); + + // Initialize the editable div + this.editInput.innerText = this.getContent(); + } + + inputSize(): number { + if (this.maxLength) { + return this.maxLength + 1; + } + // give at least 2+ chars space and grow with the content + return Math.max(2, (this.getContent() || '').length) * 1.1; + } + + focusBigText() { + const targetNode = this.editInput.firstChild; + + if (!targetNode) { + // Div contains no text content, nothing to select + return; + } + + const range = document.createRange(); + range.setStart(targetNode, 0); + range.setEnd(targetNode, targetNode.length); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } + + // Route keydown events to the appropriate handler + inputKeyDown(evt: KeyboardEvent) { + + switch(evt.key) { + case 'y': + if (evt.ctrlKey) { // redo + this.context.requestRedo(); + evt.preventDefault(); + } + return; + + case 'z': + if (evt.ctrlKey) { // undo + this.context.requestUndo(); + evt.preventDefault(); + } + return; + + case 'F6': + if (evt.shiftKey) { + // shift+F6 => add 006 + this.context.add00X('006'); + evt.preventDefault(); + evt.stopPropagation(); + } + return; + + case 'F7': + if (evt.shiftKey) { + // shift+F7 => add 007 + this.context.add00X('007'); + evt.preventDefault(); + evt.stopPropagation(); + } + return; + + case 'F8': + if (evt.shiftKey) { + // shift+F8 => add/replace 008 + this.context.insertReplace008(); + evt.preventDefault(); + evt.stopPropagation(); + } + return; + } + + // None of the remaining key combos are supported by the LDR + // or fixed field editor. + if (this.fieldType === 'ldr' || this.fieldType === 'ffld') { return; } + + switch (evt.key) { + + case 'Enter': + if (evt.ctrlKey) { + // ctrl+enter == insert stub field after focused field + // ctrl+shift+enter == insert stub field before focused field + this.context.insertStubField(this.field, evt.shiftKey); + } + + evt.preventDefault(); // Bare newlines not allowed. + break; + + case 'Delete': + + if (evt.ctrlKey) { + // ctrl+delete == delete whole field + this.context.deleteField(this.field); + evt.preventDefault(); + + } else if (evt.shiftKey && this.subfield) { + // shift+delete == delete subfield + + this.context.deleteSubfield(this.field, this.subfield); + evt.preventDefault(); + } + + break; + + case 'ArrowDown': + + if (evt.ctrlKey) { + // ctrl+down == copy current field down one + this.context.insertField( + this.field, this.record.cloneField(this.field)); + } else { + // avoid dupe focus requests + this.context.focusNextTag(this.field); + } + + evt.preventDefault(); + break; + + case 'ArrowUp': + + if (evt.ctrlKey) { + // ctrl+up == copy current field up one + this.context.insertField( + this.field, this.record.cloneField(this.field), true); + } else { + // avoid dupe focus requests + this.context.focusPreviousTag(this.field); + } + + // up == move focus to tag of previous field + evt.preventDefault(); + break; + + case 'd': // thunk + case 'i': + if (evt.ctrlKey) { + // ctrl+i / ctrl+d == insert subfield + const pos = this.subfield ? this.subfield[2] + 1 : 0; + this.context.insertStubSubfield(this.field, pos); + evt.preventDefault(); + } + break; + } + } + + insertField(before: boolean) { + + const newField = this.record.newField( + {tag: '999', subfields: [[' ', '', 0]]}); + + if (before) { + this.record.insertFieldsBefore(this.field, newField); + } else { + this.record.insertFieldsAfter(this.field, newField); + } + + this.context.requestFieldFocus( + {fieldId: newField.fieldId, target: 'tag'}); + } + + deleteField() { + this.context.focusNextTag(this.field) || + this.context.focusPreviousTag(this.field); + + this.record.deleteFields(this.field); + } + + deleteSubfield() { + // If subfields remain, focus the previous subfield. + // otherwise focus our tag. + const sfpos = this.subfield[2] - 1; + + this.field.deleteExactSubfields(this.subfield); + + const focus: FieldFocusRequest = + {fieldId: this.field.fieldId, target: 'tag'}; + + if (sfpos >= 0) { + focus.target = 'sfv'; + focus.sfOffset = sfpos; + } + + this.context.requestFieldFocus(focus); + } + + contextMenuChange(value: string) { + this.setContent(value, true); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts new file mode 100644 index 0000000000..168fbda376 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts @@ -0,0 +1,351 @@ +import {EventEmitter} from '@angular/core'; +import {MarcRecord, MarcField, MarcSubfield} from './marcrecord'; +import {NgbPopover} from '@ng-bootstrap/ng-bootstrap'; + +/* Per-instance MARC editor context. */ + +const STUB_DATA_00X = ' '; + +export type MARC_EDITABLE_FIELD_TYPE = + 'ldr' | 'tag' | 'cfld' | 'ind1' | 'ind2' | 'sfc' | 'sfv' | 'ffld'; + +export interface FieldFocusRequest { + fieldId: number; + target: MARC_EDITABLE_FIELD_TYPE; + sfOffset?: number; // focus a specific subfield by its offset + ffCode?: string; // fixed field code +} + +export class UndoRedoAction { + // Which point in the record was modified. + position: FieldFocusRequest; + + // Which stack do we toss this on once it's been applied? + isRedo: boolean; +} + +export class TextUndoRedoAction extends UndoRedoAction { + textContent: string; +} + +export class StructUndoRedoAction extends UndoRedoAction { + /* Add or remove a part of the record (field, subfield, etc.) */ + + // Does this action track an addition or deletion. + wasAddition: boolean; + + // Field to add/delete or field to modify for subfield adds/deletes + field: MarcField; + + // If this is a subfield modification. + subfield: MarcSubfield; + + // Position preceding the modified position to mark the position + // of deletion recovery. + prevPosition: FieldFocusRequest; + + // Location of the cursor at time of initial action. + prevFocus: FieldFocusRequest; +} + + +export class MarcEditContext { + + recordChange: EventEmitter; + fieldFocusRequest: EventEmitter; + textUndoRedoRequest: EventEmitter; + recordType: 'biblio' | 'authority' = 'biblio'; + + lastFocused: FieldFocusRequest = null; + + undoStack: UndoRedoAction[] = []; + redoStack: UndoRedoAction[] = []; + + private _record: MarcRecord; + set record(r: MarcRecord) { + if (r !== this._record) { + this._record = r; + this._record.stampFieldIds(); + this.recordChange.emit(r); + } + } + + get record(): MarcRecord { + return this._record; + } + + constructor() { + this.recordChange = new EventEmitter(); + this.fieldFocusRequest = new EventEmitter(); + this.textUndoRedoRequest = new EventEmitter(); + } + + requestFieldFocus(req: FieldFocusRequest) { + // timeout allows for new components to be built before the + // focus request is emitted. + setTimeout(() => this.fieldFocusRequest.emit(req)); + } + + resetUndos() { + this.undoStack = []; + this.redoStack = []; + } + + requestUndo() { + const undo = this.undoStack.shift(); + if (undo) { + undo.isRedo = false; + this.distributeUndoRedo(undo); + } + } + + requestRedo() { + const redo = this.redoStack.shift(); + if (redo) { + redo.isRedo = true; + this.distributeUndoRedo(redo); + } + } + + distributeUndoRedo(action: UndoRedoAction) { + if (action instanceof TextUndoRedoAction) { + // Let the editable content component handle it. + this.textUndoRedoRequest.emit(action); + } else { + // Manage structural changes within + this.handleStructuralUndoRedo(action as StructUndoRedoAction); + } + } + + handleStructuralUndoRedo(action: StructUndoRedoAction) { + + if (action.wasAddition) { + // Remove the added field + + if (action.subfield) { + const prevPos = action.subfield[2] - 1; + action.field.deleteExactSubfields(action.subfield); + this.focusSubfield(action.field, prevPos); + + } else { + this.record.deleteFields(action.field); + } + + // When deleting chunks, always return focus to the + // pre-insert position. + this.requestFieldFocus(action.prevFocus); + + } else { + // Re-insert the removed field and focus it. + + if (action.subfield) { + + this.insertSubfield(action.field, action.subfield, true); + this.focusSubfield(action.field, action.subfield[2]); + + } else { + + const fieldId = action.position.fieldId; + const prevField = + this.record.getField(action.prevPosition.fieldId); + + this.record.insertFieldsAfter(prevField, action.field); + + // Recover the original fieldId, which gets re-stamped + // in this.record.insertFields* calls. + action.field.fieldId = fieldId; + + // Focus the newly recovered field. + this.requestFieldFocus(action.position); + } + + // When inserting chunks, track the location where the + // insert was requested so we can return the cursor so we + // can return the cursor to the scene of the crime if the + // undo is re-done or vice versa. This is primarily useful + // when performing global inserts like add00X, which can be + // done without the 00X field itself having focus. + action.prevFocus = this.lastFocused; + } + + action.wasAddition = !action.wasAddition; + + const moveTo = action.isRedo ? this.undoStack : this.redoStack; + + moveTo.unshift(action); + } + + trackStructuralUndo(field: MarcField, isAddition: boolean, subfield?: MarcSubfield) { + + // Human-driven changes invalidate the redo stack. + this.redoStack = []; + + const position: FieldFocusRequest = {fieldId: field.fieldId, target: 'tag'}; + + let prevPos: FieldFocusRequest = null; + + if (subfield) { + position.target = 'sfc'; + position.sfOffset = subfield[2]; + + } else { + // No need to track the previous field for subfield mods. + + const prevField = this.record.getPreviousField(field.fieldId); + if (prevField) { + prevPos = {fieldId: prevField.fieldId, target: 'tag'}; + } + } + + const action = new StructUndoRedoAction(); + action.field = field; + action.subfield = subfield; + action.wasAddition = isAddition; + action.position = position; + action.prevPosition = prevPos; + + // For bulk adds (e.g. add a whole row) the field focused at + // time of action will be different than the added field. + action.prevFocus = this.lastFocused; + + this.undoStack.unshift(action); + } + + deleteField(field: MarcField) { + this.trackStructuralUndo(field, false); + + this.focusNextTag(field) || this.focusPreviousTag(field); + + this.record.deleteFields(field); + } + + add00X(tag: string) { + + const field: MarcField = + this.record.newField({tag : tag, data : STUB_DATA_00X}); + + this.record.insertOrderedFields(field); + + this.trackStructuralUndo(field, true); + + this.focusTag(field); + } + + insertReplace008() { + + // delete all of the 008s + [].concat(this.record.field('008', true)).forEach(f => { + this.trackStructuralUndo(f, false); + this.record.deleteFields(f); + }); + + const field = this.record.newField({ + tag : '008', data : this.record.generate008()}); + + this.record.insertOrderedFields(field); + + this.trackStructuralUndo(field, true); + + this.focusTag(field); + } + + // Add stub field before or after the context field + insertStubField(field: MarcField, before?: boolean) { + + const newField = this.record.newField( + {tag: '999', subfields: [[' ', '', 0]]}); + + this.insertField(field, newField, before); + } + + insertField(contextField: MarcField, newField: MarcField, before?: boolean) { + + if (before) { + this.record.insertFieldsBefore(contextField, newField); + this.focusPreviousTag(contextField); + + } else { + this.record.insertFieldsAfter(contextField, newField); + this.focusNextTag(contextField); + } + + this.trackStructuralUndo(newField, true); + } + + // Adds a new empty subfield to the provided field at the + // requested subfield position + insertSubfield(field: MarcField, + subfield: MarcSubfield, skipTracking?: boolean) { + const position = subfield[2]; + + // array index 3 contains that position of the subfield + // in the MARC field. When splicing a new subfield into + // the set, be sure the any that come after the new one + // have their positions bumped to reflect the shift. + field.subfields.forEach( + sf => {if (sf[2] >= position) { sf[2]++; }}); + + field.subfields.splice(position, 0, subfield); + + if (!skipTracking) { + this.focusSubfield(field, position); + this.trackStructuralUndo(field, true, subfield); + } + } + + insertStubSubfield(field: MarcField, position: number) { + const newSf: MarcSubfield = [' ', '', position]; + this.insertSubfield(field, newSf); + } + + // Focus the requested subfield by its position. If its + // position is less than zero, focus the field's tag instead. + focusSubfield(field: MarcField, position: number) { + + const focus: FieldFocusRequest = {fieldId: field.fieldId, target: 'tag'}; + + if (position >= 0) { + // Focus the code instead of the value, because attempting to + // focus an empty (editable) div results in nothing getting focus. + focus.target = 'sfc'; + focus.sfOffset = position; + } + + this.requestFieldFocus(focus); + } + + deleteSubfield(field: MarcField, subfield: MarcSubfield) { + const sfpos = subfield[2] - 1; // previous subfield + + this.trackStructuralUndo(field, false, subfield); + + field.deleteExactSubfields(subfield); + + this.focusSubfield(field, sfpos); + } + + focusTag(field: MarcField) { + this.requestFieldFocus({fieldId: field.fieldId, target: 'tag'}); + } + + // Returns true if the field has a next tag to focus + focusNextTag(field: MarcField) { + const nextField = this.record.getNextField(field.fieldId); + if (nextField) { + this.focusTag(nextField); + return true; + } + return false; + } + + // Returns true if the field has a previous tag to focus + focusPreviousTag(field: MarcField): boolean { + const prevField = this.record.getPreviousField(field.fieldId); + if (prevField) { + this.focusTag(prevField); + return true; + } + return false; + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html index 55c5af69ea..dea85817e1 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html @@ -19,6 +19,13 @@
+ +

+ + Record Type {{record ? record.recordType() : ''}} + +

+
- - + + -
- Enhanced MARC Editor is not yet implemented. See the - - - AngularJS MARC Editor. - - - - - AngularJS MARC Editor. - + + -
- + + +
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts index 44da299df0..138161611d 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts @@ -6,11 +6,14 @@ import {AuthService} from '@eg/core/auth.service'; import {OrgService} from '@eg/core/org.service'; import {PcrudService} from '@eg/core/pcrud.service'; import {ToastService} from '@eg/share/toast/toast.service'; +import {ServerStoreService} from '@eg/core/server-store.service'; import {StringComponent} from '@eg/share/string/string.component'; import {MarcRecord} from './marcrecord'; import {ComboboxEntry, ComboboxComponent } from '@eg/share/combobox/combobox.component'; import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component'; +import {MarcEditContext} from './editor-context'; +import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap'; interface MarcSavedEvent { marcXml: string; @@ -28,9 +31,11 @@ interface MarcSavedEvent { export class MarcEditorComponent implements OnInit { - record: MarcRecord; editorTab: 'rich' | 'flat'; sources: ComboboxEntry[]; + context: MarcEditContext; + + @Input() recordType: 'biblio' | 'authority' = 'biblio'; @Input() set recordId(id: number) { if (!id) { return; } @@ -39,7 +44,13 @@ export class MarcEditorComponent implements OnInit { } @Input() set recordXml(xml: string) { - if (xml) { this.fromXml(xml); } + if (xml) { + this.fromXml(xml); + } + } + + get record(): MarcRecord { + return this.context.record; } // Tell us which record source to select by default. @@ -70,15 +81,20 @@ export class MarcEditorComponent implements OnInit { private auth: AuthService, private org: OrgService, private pcrud: PcrudService, - private toast: ToastService + private toast: ToastService, + private store: ServerStoreService ) { this.sources = []; this.recordSaved = new EventEmitter(); + this.context = new MarcEditContext(); } ngOnInit() { - // Default to flat for now since it's all that's supported. - this.editorTab = 'flat'; + + this.context.recordType = this.recordType; + + this.store.getItem('cat.marcedit.flateditor').then( + useFlat => this.editorTab = useFlat ? 'flat' : 'rich'); this.pcrud.retrieveAll('cbs').subscribe( src => this.sources.push({id: +src.id(), label: src.source()}), @@ -95,6 +111,20 @@ export class MarcEditorComponent implements OnInit { ); } + // Remember the last used tab as the preferred tab. + tabChange(evt: NgbTabChangeEvent) { + + // Avoid undo persistence across tabs since that could result + // in changes getting lost. + this.context.resetUndos(); + + if (evt.nextId === 'flat') { + this.store.setItem('cat.marcedit.flateditor', true); + } else { + this.store.removeItem('cat.marcedit.flateditor'); + } + } + saveRecord(): Promise { const xml = this.record.toXml(); @@ -140,7 +170,7 @@ export class MarcEditorComponent implements OnInit { fromId(id: number): Promise { return this.pcrud.retrieve('bre', id) .toPromise().then(bib => { - this.record = new MarcRecord(bib.marc()); + this.context.record = new MarcRecord(bib.marc()); this.record.id = id; this.record.deleted = bib.deleted() === 't'; if (bib.source()) { @@ -150,7 +180,7 @@ export class MarcEditorComponent implements OnInit { } fromXml(xml: string) { - this.record = new MarcRecord(xml); + this.context.record = new MarcRecord(xml); this.record.id = null; } diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.css new file mode 100644 index 0000000000..88b31f275a --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.css @@ -0,0 +1,20 @@ + +:host >>> .popover { + font-family: 'Lucida Console', Monaco, monospace; + max-width: 550px; +} + +:host >>> .popover-body { + max-height: 400px; + overflow-y: auto; + overflow-x: auto; +} + +:host >>> .popover-body .menu-entry { + white-space: nowrap; +} + +:host >>> .popover-body .menu-entry:hover { + background-color: #f8f9fa; /* bootstrap color */ +} + diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html new file mode 100644 index 0000000000..e2e8976197 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html @@ -0,0 +1,16 @@ + + + +
+
+ + {{fieldLabel}} + +
+
+ + +
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts new file mode 100644 index 0000000000..a81255dc6d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts @@ -0,0 +1,46 @@ +import {Component, Input, Output, OnInit, EventEmitter} from '@angular/core'; +import {IdlObject} from '@eg/core/idl.service'; +import {MarcRecord} from './marcrecord'; +import {MarcEditContext} from './editor-context'; +import {TagTableService} from './tagtable.service'; + +/** + * MARC Fixed Field Editing Component + */ + +@Component({ + selector: 'eg-fixed-field', + templateUrl: './fixed-field.component.html', + styleUrls: ['fixed-field.component.css'] +}) + +export class FixedFieldComponent implements OnInit { + + @Input() fieldCode: string; + @Input() fieldLabel: string; + @Input() context: MarcEditContext; + + get record(): MarcRecord { return this.context.record; } + + fieldMeta: IdlObject; + randId = Math.floor(Math.random() * 10000000); + + constructor(private tagTable: TagTableService) {} + + ngOnInit() { + this.init().then(_ => + this.context.recordChange.subscribe(__ => this.init())); + } + + init(): Promise { + if (!this.record) { return Promise.resolve(); } + + // If no field metadata is found for this fixed field code and + // record type combo, the field will be hidden in the UI. + return this.tagTable.getFfFieldMeta( + this.fieldCode, this.record.recordType()) + .then(fieldMeta => this.fieldMeta = fieldMeta); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.html new file mode 100644 index 0000000000..97d866f54a --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.html @@ -0,0 +1,281 @@ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.ts new file mode 100644 index 0000000000..d02981606b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.ts @@ -0,0 +1,31 @@ +import {Component, Input, Output, OnInit, AfterViewInit, EventEmitter, + OnDestroy} from '@angular/core'; +import {IdlService} from '@eg/core/idl.service'; +import {OrgService} from '@eg/core/org.service'; +import {MarcRecord} from './marcrecord'; +import {MarcEditContext} from './editor-context'; +import {TagTableService} from './tagtable.service'; + +/** + * MARC Fixed Fields Editor Component + */ + +@Component({ + selector: 'eg-fixed-fields-editor', + templateUrl: './fixed-fields-editor.component.html' +}) + +export class FixedFieldsEditorComponent implements OnInit { + + @Input() context: MarcEditContext; + get record(): MarcRecord { return this.context.record; } + + constructor( + private idl: IdlService, + private org: OrgService, + private tagTable: TagTableService + ) {} + + ngOnInit() {} +} + diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts index b5e2f41277..465a738eb2 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts @@ -1,9 +1,9 @@ -import {Component, Input, OnInit, Host} from '@angular/core'; +import {Component, Input, OnInit} from '@angular/core'; import {IdlService} from '@eg/core/idl.service'; import {OrgService} from '@eg/core/org.service'; import {ServerStoreService} from '@eg/core/server-store.service'; -import {MarcEditorComponent} from './editor.component'; import {MarcRecord} from './marcrecord'; +import {MarcEditContext} from './editor-context'; /** * MARC Record flat text (marc-breaker) editor. @@ -17,19 +17,22 @@ import {MarcRecord} from './marcrecord'; export class MarcFlatEditorComponent implements OnInit { + @Input() context: MarcEditContext; get record(): MarcRecord { - return this.editor.record; + return this.context.record; } constructor( private idl: IdlService, private org: OrgService, - private store: ServerStoreService, - @Host() private editor: MarcEditorComponent - ) { - } + private store: ServerStoreService + ) {} - ngOnInit() {} + ngOnInit() { + // Be sure changes made in the enriched editor are + // reflected here. + this.record.breakerText = this.record.toBreaker(); + } // When we have breaker text, limit the vertical expansion of the // text area to the size of the data plus a little padding. diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts index a18eb0b7a4..c7bbaba481 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts @@ -1,22 +1,32 @@ import {NgModule} from '@angular/core'; import {StaffCommonModule} from '@eg/staff/common.module'; +import {CommonWidgetsModule} from '@eg/share/common-widgets.module'; import {MarcEditorComponent} from './editor.component'; import {MarcRichEditorComponent} from './rich-editor.component'; import {MarcFlatEditorComponent} from './flat-editor.component'; +import {FixedFieldsEditorComponent} from './fixed-fields-editor.component'; +import {FixedFieldComponent} from './fixed-field.component'; +import {TagTableService} from './tagtable.service'; +import {EditableContentComponent} from './editable-content.component'; @NgModule({ declarations: [ MarcEditorComponent, MarcRichEditorComponent, - MarcFlatEditorComponent + MarcFlatEditorComponent, + FixedFieldsEditorComponent, + FixedFieldComponent, + EditableContentComponent ], imports: [ - StaffCommonModule + StaffCommonModule, + CommonWidgetsModule ], exports: [ MarcEditorComponent ], providers: [ + TagTableService ] }) diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts index df1a492762..61b716929b 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts @@ -1,12 +1,29 @@ -/** - * Simple wrapper class for our external MARC21.Record JS library. - */ +import {EventEmitter} from '@angular/core'; + +/* Wrapper class for our external MARC21.Record JS library. */ declare var MARC21; // MARC breaker delimiter const DELIMITER = '$'; +export interface MarcSubfield // code, value, position + extends Array{0: string; 1: string; 2: number} + +// Only contains the attributes/methods we need so far. +export interface MarcField { + fieldId?: number; + data?: string; + tag?: string; + ind1?: string; + ind2?: string; + subfields?: MarcSubfield[]; + + isControlfield(): boolean; + + deleteExactSubfields(...subfield: MarcSubfield[]): number; +} + export class MarcRecord { id: number; // Database ID when known. @@ -14,9 +31,30 @@ export class MarcRecord { record: any; // MARC21.Record object breakerText: string; + // Let clients know some fixed field shuffling may have occured. + // Emits the fixed field code. + fixedFieldChange: EventEmitter; + + get leader(): string { + return this.record.leader; + } + + set leader(l: string) { + this.record.leader = l; + } + + get fields(): MarcField[] { + return this.record.fields; + } + + set fields(f: MarcField[]) { + this.record.fields = f; + } + constructor(xml: string) { this.record = new MARC21.Record({marcxml: xml, delimiter: DELIMITER}); this.breakerText = this.record.toBreaker(); + this.fixedFieldChange = new EventEmitter(); } toXml(): string { @@ -27,9 +65,112 @@ export class MarcRecord { return this.record.toBreaker(); } + recordType(): string { + return this.record.recordType(); + } + absorbBreakerChanges() { this.record = new MARC21.Record( {marcbreaker: this.breakerText, delimiter: DELIMITER}); } + + extractFixedField(fieldCode: string): string { + return this.record.extractFixedField(fieldCode); + } + + setFixedField(fieldCode: string, fieldValue: string): string { + const response = this.record.setFixedField(fieldCode, fieldValue); + this.fixedFieldChange.emit(fieldCode); + return response; + } + + // Give each field an identifier so it may be referenced later. + stampFieldIds() { + this.fields.forEach(f => this.stampFieldId(f)); + } + + stampFieldId(field: MarcField) { + if (!field.fieldId) { + field.fieldId = Math.floor(Math.random() * 10000000); + } + } + + field(spec: string, wantArray?: boolean): MarcField | MarcField[] { + return this.record.field(spec, wantArray); + } + + insertFieldsBefore(field: MarcField, ...newFields: MarcField[]) { + this.record.insertFieldsBefore.apply( + this.record, [field].concat(newFields)); + this.stampFieldIds(); + } + + insertFieldsAfter(field: MarcField, ...newFields: MarcField[]) { + this.record.insertFieldsAfter.apply( + this.record, [field].concat(newFields)); + this.stampFieldIds(); + } + + insertOrderedFields(...newFields: MarcField[]) { + this.record.insertOrderedFields.apply(this.record, newFields); + this.stampFieldIds(); + } + + generate008(): MarcField { + return this.record.generate008(); + } + + + deleteFields(...fields: MarcField[]) { + this.record.deleteFields.apply(this.record, fields); + } + + getField(id: number): MarcField { + return this.fields.filter(f => f.fieldId === id)[0]; + } + + getPreviousField(id: number): MarcField { + for (let idx = 0; idx < this.fields.length; idx++) { + if (this.fields[idx].fieldId === id) { + return this.fields[idx - 1]; + } + } + } + + getNextField(id: number): MarcField { + for (let idx = 0; idx < this.fields.length; idx++) { + if (this.fields[idx].fieldId === id) { + return this.fields[idx + 1]; + } + } + } + + // Turn an field-ish object into a proper MARC.Field + newField(props: any): MarcField { + const field = new MARC21.Field(props); + this.stampFieldId(field); + return field; + } + + cloneField(field: any): MarcField { + const props: any = {tag: field.tag}; + + if (field.isControlfield()) { + props.data = field.data; + + } else { + props.ind1 = field.ind1; + props.ind2 = field.ind2; + props.subfields = this.cloneSubfields(field.subfields); + } + + return this.newField(props); + } + + cloneSubfields(subfields: MarcSubfield[]): MarcSubfield[] { + const root = []; + subfields.forEach(sf => root.push([].concat(sf))); + return root; + } } diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css index e69de29bb2..5c90f28b9b 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css @@ -0,0 +1,8 @@ + +.fixed-fields-container { + /* + * wait for https://bugs.launchpad.net/evergreen/+bug/1735568 approval + background-color: lightcyan; + border-bottom: 1px solid gray; + */ +} diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html index e69de29bb2..cf214424d6 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html @@ -0,0 +1,139 @@ + + +
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+ + +
+
+
+ + +
+
+
+
+
+
+
+
+
    +
  • Undo: CTRL-z
  • +
  • Redo: CTRL-y
  • +
  • Add Row: CTRL+Enter
  • +
  • Insert Row: CTRL+Shift+Enter
  • +
+
+
+
    +
  • Copy Current Row Above: CTRL+Up
  • +
  • Copy Current Row Below: CTRL+Down
  • +
  • Add Subfield: CTRL+D or CTRL+I
  • +
  • Remove Row: CTRL+Del
  • +
+
+
+
    +
  • Remove Subfield: Shift+Del
  • +
  • Create/Replace 006: Shift+F6
  • +
  • Create/Replace 007: Shift+F7
  • +
  • Create/Replace 008: Shift+F8
  • +
+
+
+ + +
+ + + + + +
+ + +
+ + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts index 7f8ac334e3..1c50c57c4f 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts @@ -2,6 +2,10 @@ import {Component, Input, Output, OnInit, AfterViewInit, EventEmitter, OnDestroy} from '@angular/core'; import {IdlService} from '@eg/core/idl.service'; import {OrgService} from '@eg/core/org.service'; +import {TagTableService} from './tagtable.service'; +import {MarcRecord, MarcField} from './marcrecord'; +import {MarcEditContext} from './editor-context'; + /** * MARC Record rich editor interface. @@ -15,13 +19,60 @@ import {OrgService} from '@eg/core/org.service'; export class MarcRichEditorComponent implements OnInit { + @Input() context: MarcEditContext; + get record(): MarcRecord { return this.context.record; } + + dataLoaded: boolean; + showHelp: boolean; + randId = Math.floor(Math.random() * 100000); + stackSubfields: boolean; + constructor( private idl: IdlService, - private org: OrgService - ) { + private org: OrgService, + private tagTable: TagTableService + ) {} + + ngOnInit() { + this.init().then(_ => + this.context.recordChange.subscribe(__ => this.init())); + } + + init(): Promise { + this.dataLoaded = false; + + if (!this.record) { return Promise.resolve(); } + + return Promise.all([ + this.tagTable.loadTagTable({marcRecordType: this.context.recordType}), + this.tagTable.getFfPosTable(this.record.recordType()), + this.tagTable.getFfValueTable(this.record.recordType()) + ]).then(_ => this.dataLoaded = true); + } + + undoCount(): number { + return this.context.undoStack.length; } - ngOnInit() {} + redoCount(): number { + return this.context.redoStack.length; + } + + undo() { + this.context.requestUndo(); + } + + redo() { + this.context.requestRedo(); + } + + controlFields(): MarcField[] { + return this.record.fields.filter(f => f.isControlfield()); + } + + dataFields(): MarcField[] { + return this.record.fields.filter(f => !f.isControlfield()); + } } diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts new file mode 100644 index 0000000000..ec5abd04b3 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts @@ -0,0 +1,274 @@ +import {Injectable, EventEmitter} from '@angular/core'; +import {map, tap} from 'rxjs/operators'; +import {StoreService} from '@eg/core/store.service'; +import {IdlObject} from '@eg/core/idl.service'; +import {AuthService} from '@eg/core/auth.service'; +import {NetService} from '@eg/core/net.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {EventService} from '@eg/core/event.service'; +import {ContextMenuEntry} from '@eg/share/context-menu/context-menu.service'; + +interface TagTableSelector { + marcFormat?: string; + marcRecordType?: string; +} + +const defaultTagTableSelector: TagTableSelector = { + marcFormat : 'marc21', + marcRecordType : 'biblio' +} + +@Injectable() +export class TagTableService { + + // Current set of tags in list and map form. + tagMap: {[tag: string]: any} = {}; + ffPosMap: {[rtype: string]: any[]} = {}; + ffValueMap: {[rtype: string]: any} = {}; + + extractedValuesCache: + {[valueType: string]: {[which: string]: any}} = {}; + + constructor( + private store: StoreService, + private auth: AuthService, + private net: NetService, + private pcrud: PcrudService, + private evt: EventService + ) { + + this.extractedValuesCache = { + fieldtags: {}, + indicators: {}, + sfcodes: {}, + sfvalues: {}, + ffvalues: {} + }; + } + + // Various data needs munging for display. Cached the modified + // values since they are refernced repeatedly by the UI code. + fromCache(dataType: string, which?: string, which2?: string): ContextMenuEntry[] { + const part1 = this.extractedValuesCache[dataType][which]; + if (which2) { + if (part1) { + return part1[which2]; + } + } else { + return part1; + } + } + + toCache(dataType: string, which: string, + which2: string, values: ContextMenuEntry[]): ContextMenuEntry[] { + const base = this.extractedValuesCache[dataType]; + const part1 = base[which]; + + if (which2) { + if (!base[which]) { base[which] = {}; } + base[which][which2] = values; + } else { + base[which] = values; + } + + return values; + } + + getFfPosTable(rtype: string): Promise { + const storeKey = 'FFPosTable_' + rtype; + + if (this.ffPosMap[rtype]) { + return Promise.resolve(this.ffPosMap[rtype]); + } + + this.ffPosMap[rtype] = this.store.getLocalItem(storeKey); + + if (this.ffPosMap[rtype]) { + return Promise.resolve(this.ffPosMap[rtype]); + } + + return this.net.request( + 'open-ils.fielder', 'open-ils.fielder.cmfpm.atomic', + {query: {tag: {'!=' : '006'}, rec_type: rtype}} + + ).toPromise().then(table => { + this.store.setLocalItem(storeKey, table); + return this.ffPosMap[rtype] = table; + }); + } + + getFfValueTable(rtype: string): Promise { + + const storeKey = 'FFValueTable_' + rtype; + + if (this.ffValueMap[rtype]) { + return Promise.resolve(this.ffValueMap[rtype]); + } + + this.ffValueMap[rtype] = this.store.getLocalItem(storeKey); + + if (this.ffValueMap[rtype]) { + return Promise.resolve(this.ffValueMap[rtype]); + } + + return this.net.request( + 'open-ils.cat', + 'open-ils.cat.biblio.fixed_field_values.by_rec_type', rtype + + ).toPromise().then(table => { + this.store.setLocalItem(storeKey, table); + return this.ffValueMap[rtype] = table; + }); + } + + loadTagTable(selector?: TagTableSelector): Promise { + + if (selector) { + if (!selector.marcFormat) { + selector.marcFormat = defaultTagTableSelector.marcFormat; + } + if (!selector.marcRecordType) { + selector.marcRecordType = + defaultTagTableSelector.marcRecordType; + } + } else { + selector = defaultTagTableSelector; + } + + const cacheKey = 'FFValueTable_' + selector.marcRecordType; + + this.tagMap = this.store.getLocalItem(cacheKey); + + if (this.tagMap) { + return Promise.resolve(this.tagMap); + } + + return this.fetchTagTable(selector).then(_ => { + this.store.setLocalItem(cacheKey, this.tagMap); + return this.tagMap; + }); + } + + fetchTagTable(selector?: TagTableSelector): Promise { + this.tagMap = []; + return this.net.request( + 'open-ils.cat', + 'open-ils.cat.tag_table.all.retrieve.local', + this.auth.token(), selector.marcFormat, selector.marcRecordType + ).pipe(tap(tagData => { + this.tagMap[tagData.tag] = tagData; + })).toPromise(); + } + + getSubfieldCodes(tag: string): ContextMenuEntry[] { + if (!tag || !this.tagMap[tag]) { return null; } + + const cached = this.fromCache('sfcodes', tag); + + const list = this.tagMap[tag].subfields.map(sf => ({ + value: sf.code, + label: `${sf.code}: ${sf.description}` + })) + .sort((a, b) => a.label < b.label ? -1 : 1); + + return this.toCache('sfcodes', tag, null, list); + } + + getFieldTags(): ContextMenuEntry[] { + + const cached = this.fromCache('fieldtags'); + if (cached) { return cached; } + + return Object.keys(this.tagMap) + .filter(tag => Boolean(this.tagMap[tag])) + .map(tag => ({ + value: tag, + label: `${tag}: ${this.tagMap[tag].name}` + })) + .sort((a, b) => a.label < b.label ? -1 : 1); + } + + getSubfieldValues(tag: string, sfCode: string): ContextMenuEntry[] { + if (!tag || !this.tagMap[tag]) { return []; } + + const cached = this.fromCache('sfvalues', tag, sfCode) + if (cached) { return cached; } + + const list: ContextMenuEntry[] = []; + + this.tagMap[tag].subfields + .filter(sf => + sf.code === sfCode && sf.hasOwnProperty('value_list')) + .forEach(sf => { + sf.value_list.forEach(value => { + + let label = value.description || value.code; + let code = value.code || label; + if (code !== label) { label = `${code}: ${label}`; } + + list.push({value: code, label: label}); + }) + }); + + return this.toCache('sfvalues', tag, sfCode, list); + } + + getIndicatorValues(tag: string, which: 'ind1' | 'ind2'): ContextMenuEntry[] { + if (!tag || !this.tagMap[tag]) { return } + + const cached = this.fromCache('indicators', tag, which); + if (cached) { return cached; } + + let values = this.tagMap[tag][which]; + if (!values) { return; } + + values = values.map(value => ({ + value: value.code, + label: `${value.code}: ${value.description}` + })) + .sort((a, b) => a.label < b.label ? -1 : 1); + + return this.toCache('indicators', tag, which, values); + } + + + getFfFieldMeta(fieldCode: string, recordType: string): Promise { + return this.getFfPosTable(recordType).then(table => { + + // Note the AngJS MARC editor stores the full POS table + // for all record types in every copy of the table, hence + // the seemingly extraneous check in recordType. + return table.filter( + field => + field.fixed_field === fieldCode + && field.rec_type === recordType + )[0]; + }); + } + + + // Assumes getFfPosTable and getFfValueTable have already been + // invoked for the request record type. + getFfValues(fieldCode: string, recordType: string): ContextMenuEntry[] { + + const cached = this.fromCache('ffvalues', recordType, fieldCode); + if (cached) { return cached; } + + let values = this.ffValueMap[recordType]; + + if (!values || !values[fieldCode]) { return null; } + + // extract the canned set of possible values for our + // fixed field. Ignore those whose value exceeds the + // specified field length. + values = values[fieldCode] + .filter(val => val[0].length <= val[2]) + .map(val => ({value: val[0], label: `${val[0]}: ${val[1]}`})) + .sort((a, b) => a.label < b.label ? -1 : 1); + + return this.toCache('ffvalues', recordType, fieldCode, values); + } +} + + + diff --git a/Open-ILS/src/eg2/src/app/staff/staff.component.html b/Open-ILS/src/eg2/src/app/staff/staff.component.html index 6cc1bc02fd..78657470cf 100644 --- a/Open-ILS/src/eg2/src/app/staff/staff.component.html +++ b/Open-ILS/src/eg2/src/app/staff/staff.component.html @@ -25,3 +25,5 @@ + + diff --git a/Open-ILS/src/eg2/src/styles.css b/Open-ILS/src/eg2/src/styles.css index ef97e2a93d..9c120cce7d 100644 --- a/Open-ILS/src/eg2/src/styles.css +++ b/Open-ILS/src/eg2/src/styles.css @@ -219,3 +219,4 @@ body>.dropdown-menu {z-index: 2100;} background-color: #c9efe4; color: black; } + -- 2.11.0