From 9f563752f42c91127bd5f2ef056d0b97b8e6007f Mon Sep 17 00:00:00 2001 From: zman13 Date: Thu, 21 Dec 2017 14:43:11 -0500 Subject: [PATCH] Right Arrow convenience functionality The right arrow key can be used to populate the input box /text area with the currently selected text. This is handy for autocomplete type scenarios where you have long but similar fields that you may want to skip to. An example of this is when you have table.navigationfield.field1 and would like to skip to the end and select table.navigationfield.field9. With the right arrow feature just get to the first entry (table.navigationfield.field1) and then type right arrow + backspace + 9 and you don't have to scroll all the way through --- mention.directive.ts | 285 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 mention.directive.ts diff --git a/mention.directive.ts b/mention.directive.ts new file mode 100644 index 000000000..ef5073c8d --- /dev/null +++ b/mention.directive.ts @@ -0,0 +1,285 @@ +import { Directive, ElementRef, Input, ComponentFactoryResolver, ViewContainerRef, TemplateRef } from "@angular/core"; +import { EventEmitter, Output, OnInit, OnChanges, SimpleChanges } from "@angular/core"; + +import { MentionListComponent } from './mention-list.component'; +import { getValue, insertValue, getCaretPosition, setCaretPosition } from './mention-utils'; + +const KEY_BACKSPACE = 8; +const KEY_TAB = 9; +const KEY_ENTER = 13; +const KEY_SHIFT = 16; +const KEY_ESCAPE = 27; +const KEY_SPACE = 32; +const KEY_LEFT = 37; +const KEY_UP = 38; +const KEY_RIGHT = 39; +const KEY_DOWN = 40; +const KEY_2 = 50; + +/** + * Angular 2 Mentions. + * https://github.com/dmacfarlane/angular-mentions + * + * Copyright (c) 2017 Dan MacFarlane + */ +@Directive({ + selector: '[mention]', + host: { + '(keydown)': 'keyHandler($event)', + '(blur)': 'blurHandler($event)' + } +}) +export class MentionDirective implements OnInit, OnChanges { + + @Input() set mention(items:any[]){ + this.items = items; + } + + @Input() set mentionConfig(config:any) { + this.triggerChar = config.triggerChar || this.triggerChar; + this.keyCodeSpecified = typeof this.triggerChar === 'number' + this.labelKey = config.labelKey || this.labelKey; + this.disableSearch = config.disableSearch || this.disableSearch; + this.maxItems = config.maxItems || this.maxItems; + this.mentionSelect = config.mentionSelect || this.mentionSelect; + } + + // template to use for rendering list items + @Input() mentionListTemplate: TemplateRef; + + // event emitted whenever the search term changes + @Output() searchTerm = new EventEmitter(); + + // the character that will trigger the menu behavior + private triggerChar: string | number = "@"; + + // option to specify the field in the objects to be used as the item label + private labelKey:string = 'label'; + + // option to diable internal filtering. can be used to show the full list returned + // from an async operation (or allows a custom filter function to be used - in future) + private disableSearch:boolean = false; + + // option to limit the number of items shown in the pop-up menu + private maxItems:number = -1; + + // optional function to format the selected item before inserting the text + private mentionSelect: (item: any) => (string) = (item: any) => this.triggerChar + item[this.labelKey]; + + searchString: string; + startPos: number; + items: any[]; + startNode; + searchList: MentionListComponent; + stopSearch: boolean; + iframe: any; // optional + keyCodeSpecified: boolean; + + constructor( + private _element: ElementRef, + private _componentResolver: ComponentFactoryResolver, + private _viewContainerRef: ViewContainerRef + ) {} + + ngOnInit() { + if (this.items && this.items.length>0) { + if (typeof this.items[0] == 'string') { + // convert strings to objects + const me = this; + this.items = this.items.map(function(label){ + let object = {}; + object[me.labelKey] = label; + return object; + }); + } + // remove items without an labelKey (as it's required to filter the list) + this.items = this.items.filter(e => e[this.labelKey]); + this.items.sort((a,b)=>a[this.labelKey].localeCompare(b[this.labelKey])); + if (this.searchList && !this.searchList.hidden) { + this.updateSearchList(); + } + } + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['mention']) { + this.ngOnInit(); + console.log("Got Change") + } + } + + setIframe(iframe: HTMLIFrameElement) { + this.iframe = iframe; + } + + stopEvent(event: any) { + //if (event instanceof KeyboardEvent) { // does not work for iframe + if (!event.wasClick) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + } + } + + blurHandler(event: any) { + this.stopEvent(event); + this.stopSearch = true; + if (this.searchList) { + this.searchList.hidden = true; + } + } + + keyHandler(event: any, nativeElement: HTMLInputElement = this._element.nativeElement) { + let val: string = getValue(nativeElement); + let pos = getCaretPosition(nativeElement, this.iframe); + let charPressed = this.keyCodeSpecified ? event.keyCode : event.key; + if (!charPressed) { + let charCode = event.which || event.keyCode; + if (!event.shiftKey && (charCode >= 65 && charCode <= 90)) { + charPressed = String.fromCharCode(charCode + 32); + } + else if (event.shiftKey && charCode === KEY_2) { + charPressed = this.triggerChar; + } + else { + // TODO (dmacfarlane) fix this for non-alpha keys + // http://stackoverflow.com/questions/2220196/how-to-decode-character-pressed-from-jquerys-keydowns-event-handler?lq=1 + charPressed = String.fromCharCode(event.which || event.keyCode); + } + } + if (event.keyCode == KEY_ENTER && event.wasClick && pos < this.startPos) { + // put caret back in position prior to contenteditable menu click + pos = this.startNode.length; + setCaretPosition(this.startNode, pos, this.iframe); + } + //console.log("keyHandler", this.startPos, pos, val, charPressed, event); + if (charPressed == this.triggerChar) { + this.startPos = pos; + this.startNode = (this.iframe ? this.iframe.contentWindow.getSelection() : window.getSelection()).anchorNode; + this.stopSearch = false; + this.searchString = null; + this.showSearchList(nativeElement); + this.updateSearchList(); + } + else if (this.startPos >= 0 && !this.stopSearch) { + if (pos <= this.startPos) { + this.searchList.hidden = true; + } + // ignore shift when pressed alone, but not when used with another key + else if (event.keyCode !== KEY_SHIFT && + !event.metaKey && + !event.altKey && + !event.ctrlKey && + pos > this.startPos + ) { + if (event.keyCode === KEY_SPACE) { + this.startPos = -1; + } + else if (event.keyCode === KEY_BACKSPACE && pos > 0) { + pos--; + if (pos==0) { + this.stopSearch = true; + } + this.searchList.hidden = this.stopSearch; + } + else if (!this.searchList.hidden) { + if (event.keyCode === KEY_TAB || event.keyCode === KEY_ENTER) { + this.stopEvent(event); + this.searchList.hidden = true; + // value is inserted without a trailing space for consistency + // between element types (div and iframe do not preserve the space) + insertValue(nativeElement, this.startPos, pos, + this.mentionSelect(this.searchList.activeItem), this.iframe); + // fire input event so angular bindings are updated + if ("createEvent" in document) { + var evt = document.createEvent("HTMLEvents"); + evt.initEvent("input", false, true); + nativeElement.dispatchEvent(evt); + } + this.startPos = -1; + return false; + } + else if (event.keyCode === KEY_ESCAPE) { + this.stopEvent(event); + this.searchList.hidden = true; + this.stopSearch = true; + return false; + } + else if (event.keyCode === KEY_DOWN) { + this.stopEvent(event); + this.searchList.activateNextItem(); + return false; + } + else if (event.keyCode === KEY_UP) { + this.stopEvent(event); + this.searchList.activatePreviousItem(); + return false; + } + } + if (event.keyCode === KEY_LEFT ) { + this.stopEvent(event); + return false; + } + if (event.keyCode === KEY_RIGHT) { + insertValue(nativeElement, this.startPos, pos, + this.triggerChar + this.searchList.activeItem[this.labelKey], this.iframe); + this.stopEvent(event); + return false; + } + else { + let mention = val.substring(this.startPos + 1, pos); + if (event.keyCode !== KEY_BACKSPACE) { + mention += charPressed; + } + this.searchString = mention; + this.searchTerm.emit(this.searchString); + this.updateSearchList(); + } + } + } + } + + updateSearchList() { + console.log("updateSearchList Started") + let matches: any[] = []; + if (this.items) { + let objects = this.items; + // disabling the search relies on the async operation to do the filtering + if (!this.disableSearch && this.searchString) { + let searchStringLowerCase = this.searchString.toLowerCase(); + objects = this.items.filter(e => e[this.labelKey].toLowerCase().startsWith(searchStringLowerCase)); + } + matches = objects; + if (this.maxItems > 0) { + matches = matches.slice(0, this.maxItems); + } + } + // update the search list + if (this.searchList) { + this.searchList.items = matches; + this.searchList.hidden = matches.length == 0; + } + } + + showSearchList(nativeElement: HTMLInputElement) { + console.log("showSearchList Started") + if (this.searchList == null) { + let componentFactory = this._componentResolver.resolveComponentFactory(MentionListComponent); + let componentRef = this._viewContainerRef.createComponent(componentFactory); + this.searchList = componentRef.instance; + this.searchList.position(nativeElement, this.iframe); + this.searchList.itemTemplate = this.mentionListTemplate; + this.searchList.labelKey = this.labelKey; + componentRef.instance['itemClick'].subscribe(() => { + nativeElement.focus(); + let fakeKeydown = {"keyCode":KEY_ENTER,"wasClick":true}; + this.keyHandler(fakeKeydown, nativeElement); + }); + } + else { + this.searchList.activeIndex = 0; + this.searchList.position(nativeElement, this.iframe); + window.setTimeout(() => this.searchList.resetScroll()); + } + } +}