Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Right Arrow convenience functionality #61

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
285 changes: 285 additions & 0 deletions mention.directive.ts
Original file line number Diff line number Diff line change
@@ -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<any>;

// 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());
}
}
}