From 1696aa6c317e7d1ae4151eeec1f4482e354c2e22 Mon Sep 17 00:00:00 2001 From: Marcelo Rivera <malrogf@gmail.com> Date: Mon, 15 Jul 2019 10:45:28 -0300 Subject: [PATCH] (feat): add support for minds-textarea (feat): added posts-autocomplete component (fix): reformat textinput autocomplete (feat): moved findSuggestions to a service --- package.json | 2 +- .../posts-autocomplete.component.scss | 7 + .../posts-autocomplete.component.ts | 34 ++ ...-input-autocomplete-container.component.ts | 20 +- .../text-input-autocomplete-menu.component.ts | 186 +++---- .../text-input-autocomplete.directive.ts | 458 ++++++++++-------- .../text-input-autocomplete.module.ts | 7 +- src/app/helpers/contenteditable-caret.ts | 58 +++ src/app/modules/comments/comments.module.ts | 2 + .../modules/comments/list/list.component.scss | 5 +- .../comments/poster/poster.component.html | 30 +- .../comments/poster/poster.component.ts | 3 + .../newsfeed/poster/poster.component.html | 29 +- .../newsfeed/poster/poster.component.scss | 5 - .../newsfeed/poster/poster.component.ts | 32 +- .../autocomplete-suggestions.service.ts | 40 ++ .../modules/suggestions/suggestions.module.ts | 4 + 17 files changed, 545 insertions(+), 377 deletions(-) create mode 100644 src/app/common/components/autocomplete/item-renderers/posts-autocomplete.component.scss create mode 100644 src/app/common/components/autocomplete/item-renderers/posts-autocomplete.component.ts create mode 100644 src/app/helpers/contenteditable-caret.ts create mode 100644 src/app/modules/suggestions/services/autocomplete-suggestions.service.ts diff --git a/package.json b/package.json index 3ef32395a2..e5c541d526 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "prebuild": "gulp build.sass", "build": "ng build --prod", "prebuild-dev": "gulp build.sass --deploy-url=http://localhost/en", - "build-dev": "ng build --output-path dist/en --deploy-url=/en/ --watch=true", + "build-dev": "ng build --output-path dist/en --deploy-url=/en/ --watch=true --poll=800", "test": "ng test", "lint": "ng lint", "e2e": "cypress run --debug", diff --git a/src/app/common/components/autocomplete/item-renderers/posts-autocomplete.component.scss b/src/app/common/components/autocomplete/item-renderers/posts-autocomplete.component.scss new file mode 100644 index 0000000000..d0c94a6874 --- /dev/null +++ b/src/app/common/components/autocomplete/item-renderers/posts-autocomplete.component.scss @@ -0,0 +1,7 @@ +m-post-autocomplete-item-renderer { + .m-postAutocompleteItemRenderer__avatar { + margin-right: 4px; + height: 32px; + border-radius: 50%; + } +} diff --git a/src/app/common/components/autocomplete/item-renderers/posts-autocomplete.component.ts b/src/app/common/components/autocomplete/item-renderers/posts-autocomplete.component.ts new file mode 100644 index 0000000000..e15d175a9f --- /dev/null +++ b/src/app/common/components/autocomplete/item-renderers/posts-autocomplete.component.ts @@ -0,0 +1,34 @@ +import { Component, Input, OnInit } from '@angular/core'; + +@Component({ + selector: 'm-post-autocomplete-item-renderer', + template: ` + <ng-container *ngIf="choice?.type == 'user'; else hashtagBlock"> + <a + href="javascript:;" + (click)="selectChoice.next(choice.username)" + > + <img + class="m-postAutocompleteItemRenderer__avatar mdl-shadow--2dp" + [src]="minds.cdn_url + 'icon/' + choice.guid + '/medium/' + choice.icontime"> + {{ choice.username }} + </a> + </ng-container> + + <ng-template #hashtagBlock> + <a + href="javascript:;" + (click)="selectChoice.next(choice)" + > + #{{ choice }} + </a> + </ng-template> + ` +}) + +export class PostsAutocompleteItemRendererComponent { + @Input() choice; + @Input() selectChoice; + + minds = window.Minds; +} diff --git a/src/app/common/components/autocomplete/text-input-autocomplete-container.component.ts b/src/app/common/components/autocomplete/text-input-autocomplete-container.component.ts index 3fa83529af..1a9a09be3b 100644 --- a/src/app/common/components/autocomplete/text-input-autocomplete-container.component.ts +++ b/src/app/common/components/autocomplete/text-input-autocomplete-container.component.ts @@ -1,16 +1,16 @@ import { Component } from '@angular/core'; @Component({ - selector: 'm-text-input--autocomplete-container', - styles: [ - ` - :host { - position: relative; - display: block; - } - ` - ], - template: '<ng-content></ng-content>' + selector: 'm-text-input--autocomplete-container', + styles: [ + ` + :host { + position: relative; + display: block; + } + ` + ], + template: '<ng-content></ng-content>' }) export class TextInputAutocompleteContainerComponent { } diff --git a/src/app/common/components/autocomplete/text-input-autocomplete-menu.component.ts b/src/app/common/components/autocomplete/text-input-autocomplete-menu.component.ts index 9247db8770..fcc16bc0a0 100644 --- a/src/app/common/components/autocomplete/text-input-autocomplete-menu.component.ts +++ b/src/app/common/components/autocomplete/text-input-autocomplete-menu.component.ts @@ -2,113 +2,113 @@ import { Component, ElementRef, HostListener, OnInit, ViewChild } from '@angular import { Subject } from 'rxjs'; @Component({ - selector: 'm-text-input--autocomplete-menu', - template: ` - <ul - *ngIf="choices?.length > 0" - #dropdownMenu - class="dropdown-menu" - [style.top.px]="position?.top" - [style.left.px]="position?.left"> - <li - *ngFor="let choice of choices; trackBy:trackById" - [class.active]="activeChoice === choice" - > - <ng-container - [ngTemplateOutlet]="itemTemplate" - [ngTemplateOutletContext]="{choice: choice, selectChoice: selectChoice}" - > - </ng-container> - </li> - </ul> + selector: 'm-text-input--autocomplete-menu', + template: ` + <ul + *ngIf="choices?.length > 0" + #dropdownMenu + class="dropdown-menu" + [style.top.px]="position?.top" + [style.left.px]="position?.left"> + <li + *ngFor="let choice of choices; trackBy:trackById" + [class.active]="activeChoice === choice" + > + <ng-container + [ngTemplateOutlet]="itemTemplate" + [ngTemplateOutletContext]="{choice: choice, selectChoice: selectChoice}" + > + </ng-container> + </li> + </ul> - <ng-template #defaultItemTemplate let-choice="choice" let-selectChoice="selectChoice"> - <a - href="javascript:;" - (click)="selectChoice.next(choice)" - > - {{ choice }} - </a> - </ng-template> - `, - styles: [ - ` - .dropdown-menu { - display: block; - max-height: 200px; - overflow-y: auto; - } - ` - ] + <ng-template #defaultItemTemplate let-choice="choice" let-selectChoice="selectChoice"> + <a + href="javascript:;" + (click)="selectChoice.next(choice)" + > + {{ choice }} + </a> + </ng-template> + `, + styles: [ + ` + .dropdown-menu { + display: block; + max-height: 200px; + overflow-y: auto; + } + ` + ] }) export class TextInputAutocompleteMenuComponent implements OnInit { - @ViewChild('dropdownMenu', { static: true }) dropdownMenuElement: ElementRef<HTMLUListElement>; - @ViewChild('defaultItemTemplate', { static: true }) defaultItemTemplate; - itemTemplate: any; - position: { top: number; left: number }; - selectChoice = new Subject(); - activeChoice: any; - searchText: string; - choiceLoadError: any; - choiceLoading = false; - private _choices: any[]; - trackById = (index: number, choice: any) => - typeof choice.id !== 'undefined' ? choice.id : choice; + @ViewChild('dropdownMenu', { static: true }) dropdownMenuElement: ElementRef<HTMLUListElement>; + @ViewChild('defaultItemTemplate', { static: true }) defaultItemTemplate; + itemTemplate: any; + position: { top: number; left: number }; + selectChoice = new Subject(); + activeChoice: any; + searchText: string; + choiceLoadError: any; + choiceLoading = false; + private _choices: any[]; + trackById = (index: number, choice: any) => + typeof choice.id !== 'undefined' ? choice.id : choice; - set choices(choices: any[]) { - this._choices = choices; - if (choices.indexOf(this.activeChoice) === -1 && choices.length > 0) { - this.activeChoice = choices[0]; - } + set choices(choices: any[]) { + this._choices = choices; + if (choices.indexOf(this.activeChoice) === -1 && choices.length > 0) { + this.activeChoice = choices[0]; } + } - get choices() { - return this._choices; - } + get choices() { + return this._choices; + } - ngOnInit() { - if (!this.itemTemplate) { - this.itemTemplate = this.defaultItemTemplate; - } + ngOnInit() { + if (!this.itemTemplate) { + this.itemTemplate = this.defaultItemTemplate; } + } - @HostListener('document:keydown.ArrowDown', ['$event']) - onArrowDown(event: KeyboardEvent) { - event.preventDefault(); - const index = this.choices.indexOf(this.activeChoice); - if (this.choices[index + 1]) { - this.scrollToChoice(index + 1); - } + @HostListener('document:keydown.ArrowDown', ['$event']) + onArrowDown(event: KeyboardEvent) { + event.preventDefault(); + const index = this.choices.indexOf(this.activeChoice); + if (this.choices[index + 1]) { + this.scrollToChoice(index + 1); } + } - @HostListener('document:keydown.ArrowUp', ['$event']) - onArrowUp(event: KeyboardEvent) { - event.preventDefault(); - const index = this.choices.indexOf(this.activeChoice); - if (this.choices[index - 1]) { - this.scrollToChoice(index - 1); - } + @HostListener('document:keydown.ArrowUp', ['$event']) + onArrowUp(event: KeyboardEvent) { + event.preventDefault(); + const index = this.choices.indexOf(this.activeChoice); + if (this.choices[index - 1]) { + this.scrollToChoice(index - 1); } + } - @HostListener('document:keydown.Enter', ['$event']) - onEnter(event: KeyboardEvent) { - if (this.choices.indexOf(this.activeChoice) > -1) { - event.preventDefault(); - this.selectChoice.next(this.activeChoice); - } + @HostListener('document:keydown.Enter', ['$event']) + onEnter(event: KeyboardEvent) { + if (this.choices.indexOf(this.activeChoice) > -1) { + event.preventDefault(); + this.selectChoice.next(this.activeChoice); } + } - private scrollToChoice(index: number) { - this.activeChoice = this._choices[index]; - if (this.dropdownMenuElement) { - const ulPosition = this.dropdownMenuElement.nativeElement.getBoundingClientRect(); - const li = this.dropdownMenuElement.nativeElement.children[index]; - const liPosition = li.getBoundingClientRect(); - if (liPosition.top < ulPosition.top) { - li.scrollIntoView(); - } else if (liPosition.bottom > ulPosition.bottom) { - li.scrollIntoView(false); - } - } + private scrollToChoice(index: number) { + this.activeChoice = this._choices[index]; + if (this.dropdownMenuElement) { + const ulPosition = this.dropdownMenuElement.nativeElement.getBoundingClientRect(); + const li = this.dropdownMenuElement.nativeElement.children[index]; + const liPosition = li.getBoundingClientRect(); + if (liPosition.top < ulPosition.top) { + li.scrollIntoView(); + } else if (liPosition.bottom > ulPosition.bottom) { + li.scrollIntoView(false); + } } + } } diff --git a/src/app/common/components/autocomplete/text-input-autocomplete.directive.ts b/src/app/common/components/autocomplete/text-input-autocomplete.directive.ts index d93ee81147..270ace9456 100644 --- a/src/app/common/components/autocomplete/text-input-autocomplete.directive.ts +++ b/src/app/common/components/autocomplete/text-input-autocomplete.directive.ts @@ -1,230 +1,280 @@ import { - ComponentFactoryResolver, - ComponentRef, - Directive, - ElementRef, - EventEmitter, - HostListener, - Injector, - Input, - OnDestroy, - Output, - ViewContainerRef + ComponentFactoryResolver, + ComponentRef, + Directive, + ElementRef, + EventEmitter, + HostListener, + Injector, + Input, + OnDestroy, + Output, + ViewContainerRef } from '@angular/core'; -import getCaretCoordinates from 'textarea-caret'; import { takeUntil } from 'rxjs/operators'; import { TextInputAutocompleteMenuComponent } from './text-input-autocomplete-menu.component'; import { Subject } from 'rxjs'; +import getCaretCoordinates from 'textarea-caret'; +import { getContentEditableCaretCoordinates } from "../../../helpers/contenteditable-caret"; export interface ChoiceSelectedEvent { - choice: any; - insertedAt: { - start: number; - end: number; - }; + choice: any; + insertedAt: { + start: number; + end: number; + }; } @Directive({ - selector: - 'textarea[mTextInputAutocomplete],input[type="text"][mTextInputAutocomplete]' + selector: + 'minds-textarea[mTextInputAutocomplete],textarea[mTextInputAutocomplete],input[type="text"][mTextInputAutocomplete]' }) export class TextInputAutocompleteDirective implements OnDestroy { - triggerCharacter: string; - /** - * The character that will trigger the menu to appear - */ - @Input() triggerCharacters = ['@']; - - /** - * The regular expression that will match the search text after the trigger character - */ - @Input() searchRegexp = /^\w*$/; - - /** - * The menu component to show with available options. - * You can extend the built in `TextInputAutocompleteMenuComponent` component to use a custom template - */ - @Input() menuComponent = TextInputAutocompleteMenuComponent; - - @Input() itemTemplate: any; - - /** - * Called when the options menu is shown - */ - @Output() menuShown = new EventEmitter(); - - /** - * Called when the options menu is hidden - */ - @Output() menuHidden = new EventEmitter(); - - /** - * Called when a choice is selected - */ - @Output() choiceSelected = new EventEmitter<ChoiceSelectedEvent>(); - - /** - * A function that accepts a search string and returns an array of choices. Can also return a promise. - */ - @Input() - findChoices: ( - searchText: string, - triggerCharacter?: string - ) => any[] | Promise<any[]>; - - /** - * A function that formats the selected choice once selected. - */ - @Input() - getChoiceLabel: (choice: any, triggerCharacter?: any) => string = choice => choice; - - /* tslint:disable member-ordering */ - private menu: - | { - component: ComponentRef<TextInputAutocompleteMenuComponent>; - triggerCharacterPosition: number; - lastCaretPosition?: number; - } - | undefined; - - private menuHidden$ = new Subject(); - - constructor( - private componentFactoryResolver: ComponentFactoryResolver, - private viewContainerRef: ViewContainerRef, - private injector: Injector, - private elm: ElementRef - ) {} - - @HostListener('keypress', ['$event.key']) - onKeypress(key: string) { - const index: number = this.triggerCharacters.indexOf(key); - if (index !== -1) { - this.triggerCharacter = this.triggerCharacters[index]; - this.showMenu(); - } + triggerCharacter: string; + /** + * The character that will trigger the menu to appear + */ + @Input() triggerCharacters = ['@']; + + /** + * The regular expression that will match the search text after the trigger character + */ + @Input() searchRegexp = /^\w*$/; + + /** + * The menu component to show with available options. + * You can extend the built in `TextInputAutocompleteMenuComponent` component to use a custom template + */ + @Input() menuComponent = TextInputAutocompleteMenuComponent; + + @Input() itemTemplate: any; + + /** + * Called when the options menu is shown + */ + @Output() menuShown = new EventEmitter(); + + /** + * Called when the options menu is hidden + */ + @Output() menuHidden = new EventEmitter(); + + /** + * Called when a choice is selected + */ + @Output() choiceSelected = new EventEmitter<ChoiceSelectedEvent>(); + + /** + * A function that accepts a search string and returns an array of choices. Can also return a promise. + */ + @Input() + findChoices: ( + searchText: string, + triggerCharacter?: string + ) => any[] | Promise<any[]>; + + /** + * A function that formats the selected choice once selected. + */ + @Input() + getChoiceLabel: (choice: any, triggerCharacter?: any) => string = choice => choice; + + /* tslint:disable member-ordering */ + private menu: + | { + component: ComponentRef<TextInputAutocompleteMenuComponent>; + triggerCharacterPosition: number; + lastCaretPosition?: number; + } + | undefined; + + private menuHidden$ = new Subject(); + + constructor( + private componentFactoryResolver: ComponentFactoryResolver, + private viewContainerRef: ViewContainerRef, + private injector: Injector, + private elm: ElementRef + ) { + } + + @HostListener('keypress', ['$event.key']) + onKeypress(key: string) { + const index: number = this.triggerCharacters.indexOf(key); + if (index !== -1) { + this.triggerCharacter = this.triggerCharacters[index]; + this.showMenu(); } + } - @HostListener('input', ['$event.target.value']) - onChange(value: string) { - if (this.menu) { - if ( - this.triggerCharacters.indexOf( - value[this.menu.triggerCharacterPosition] - ) === -1 - ) { - this.hideMenu(); - } else { - const cursor = this.elm.nativeElement.selectionStart; - if (cursor < this.menu.triggerCharacterPosition) { - this.hideMenu(); - } else { - const searchText = value.slice( - this.menu.triggerCharacterPosition + 1, - cursor - ); - if (!searchText.match(this.searchRegexp)) { - this.hideMenu(); - } else { - this.menu.component.instance.searchText = searchText; - this.menu.component.instance.choices = []; - this.menu.component.instance.choiceLoadError = undefined; - this.menu.component.instance.choiceLoading = true; - this.menu.component.changeDetectorRef.detectChanges(); - Promise.resolve(this.findChoices(searchText, this.triggerCharacter)) - .then(choices => { - if (this.menu) { - this.menu.component.instance.choices = choices; - this.menu.component.instance.choiceLoading = false; - this.menu.component.changeDetectorRef.detectChanges(); - } - }) - .catch(err => { - if (this.menu) { - this.menu.component.instance.choiceLoading = false; - this.menu.component.instance.choiceLoadError = err; - this.menu.component.changeDetectorRef.detectChanges(); - } - }); - } + @HostListener('input', ['$event']) + onChange(event: any) { + const value: string = event.target.value || event.target.textContent; + if (this.menu) { + if ( + this.triggerCharacters.indexOf( + value[this.menu.triggerCharacterPosition] + ) === -1 + ) { + this.hideMenu(); + } else { + const cursor = this.elm.nativeElement.selectionStart; + if (cursor < this.menu.triggerCharacterPosition) { + this.hideMenu(); + } else { + const searchText = value.slice( + this.menu.triggerCharacterPosition + 1, + cursor + ); + if (!searchText.match(this.searchRegexp)) { + this.hideMenu(); + } else { + this.menu.component.instance.searchText = searchText; + this.menu.component.instance.choices = []; + this.menu.component.instance.choiceLoadError = undefined; + this.menu.component.instance.choiceLoading = true; + this.menu.component.changeDetectorRef.detectChanges(); + Promise.resolve(this.findChoices(searchText, this.triggerCharacter)) + .then(choices => { + if (this.menu) { + this.menu.component.instance.choices = choices; + this.menu.component.instance.choiceLoading = false; + this.menu.component.changeDetectorRef.detectChanges(); } - } + }) + .catch(err => { + if (this.menu) { + this.menu.component.instance.choiceLoading = false; + this.menu.component.instance.choiceLoadError = err; + this.menu.component.changeDetectorRef.detectChanges(); + } + }); + } } + } } + } - @HostListener('blur') - onBlur() { - if (this.menu) { - this.menu.lastCaretPosition = this.elm.nativeElement.selectionStart; - } + @HostListener('blur') + onBlur() { + if (this.menu) { + this.menu.lastCaretPosition = this.getTriggerCharPosition(this.elm.nativeElement); } + } - private showMenu() { - if (!this.menu) { - const menuFactory = this.componentFactoryResolver.resolveComponentFactory< - TextInputAutocompleteMenuComponent - >(this.menuComponent); - this.menu = { - component: this.viewContainerRef.createComponent( - menuFactory, - 0, - this.injector - ), - triggerCharacterPosition: this.elm.nativeElement.selectionStart - }; - const lineHeight = +getComputedStyle( - this.elm.nativeElement - ).lineHeight!.replace(/px$/, ''); - const { top, left } = getCaretCoordinates( - this.elm.nativeElement, - this.elm.nativeElement.selectionStart - ); - this.menu.component.instance.itemTemplate = this.itemTemplate; - this.menu.component.instance.position = { - top: top + lineHeight, - left - }; - this.menu.component.changeDetectorRef.detectChanges(); - this.menu.component.instance.selectChoice - .pipe(takeUntil(this.menuHidden$)) - .subscribe(choice => { - const label = this.getChoiceLabel(choice, this.triggerCharacter); - const textarea: HTMLTextAreaElement = this.elm.nativeElement; - const value: string = textarea.value; - const startIndex = this.menu!.triggerCharacterPosition; - const start = value.slice(0, startIndex); - const caretPosition = - this.menu!.lastCaretPosition || textarea.selectionStart; - const end = value.slice(caretPosition); - textarea.value = start + label + end; - // force ng model / form control to update - textarea.dispatchEvent(new Event('input')); - this.hideMenu(); - const setCursorAt = (start + label).length; - textarea.setSelectionRange(setCursorAt, setCursorAt); - textarea.focus(); - this.choiceSelected.emit({ - choice, - insertedAt: { - start: startIndex, - end: startIndex + label.length - } - }); - }); - this.menuShown.emit(); - } + private getTriggerCharPosition(element) { + if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) { + return this.elm.nativeElement.selectionStart; + } else { + return getContentEditableCaretCoordinates(element).start; } + } - private hideMenu() { - if (this.menu) { - this.menu.component.destroy(); - this.menuHidden$.next(); - this.menuHidden.emit(); - this.menu = undefined; - } + private showMenu() { + if (!this.menu) { + const menuFactory = this.componentFactoryResolver.resolveComponentFactory<TextInputAutocompleteMenuComponent>(this.menuComponent); + this.menu = { + component: this.viewContainerRef.createComponent( + menuFactory, + 0, + this.injector + ), + triggerCharacterPosition: this.getTriggerCharPosition(this.elm.nativeElement) + }; + const lineHeight = +getComputedStyle( + this.elm.nativeElement + ).lineHeight!.replace(/px$/, ''); + + const { top, left } = this.elm.nativeElement instanceof HTMLTextAreaElement ? + <any>getCaretCoordinates( + this.elm.nativeElement, + this.elm.nativeElement.selectionStart + ) : + getContentEditableCaretCoordinates(this.elm.nativeElement) + ; + this.menu.component.instance.itemTemplate = this.itemTemplate; + this.menu.component.instance.position = { + top: top + lineHeight, + left + }; + this.menu.component.changeDetectorRef.detectChanges(); + this.menu.component.instance.selectChoice + .pipe(takeUntil(this.menuHidden$)) + .subscribe(choice => { + const label = this.getChoiceLabel(choice, this.triggerCharacter); + let element: any = this.elm.nativeElement; + + if (element.nodeName === 'MINDS-TEXTAREA') { + element = element.firstChild; + } + let value: string; + let selectionStart; + if (element instanceof HTMLTextAreaElement) { + value = element.value; + selectionStart = element.selectionStart; + } else { + value = element.textContent; + selectionStart = getContentEditableCaretCoordinates(element).start; + } + + const startIndex = this.menu!.triggerCharacterPosition; + + const start = value.slice(0, startIndex); + + const caretPosition = + this.menu!.lastCaretPosition || selectionStart || 0; + + const end = value.slice(caretPosition); + + value = start + label + end; + + if (element instanceof HTMLDivElement) { + element.textContent = value; + } else { + element.value = value; + } + // force ng model / form control to update + element.dispatchEvent(new Event('input')); + this.hideMenu(); + const setCursorAt = (start + label).length; + + if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) { + element.setSelectionRange(setCursorAt, setCursorAt); + } else { + const range = document.createRange(); + const sel = window.getSelection(); + + range.setStart(element.firstChild, setCursorAt); + range.setEnd(element.firstChild, setCursorAt); + //range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } + + element.focus(); + + this.choiceSelected.emit({ + choice, + insertedAt: { + start: startIndex, + end: startIndex + label.length + } + }); + }); + this.menuShown.emit(); } + } - ngOnDestroy() { - this.hideMenu(); + private hideMenu() { + if (this.menu) { + this.menu.component.destroy(); + this.menuHidden$.next(); + this.menuHidden.emit(); + this.menu = undefined; } + } + + ngOnDestroy() { + this.hideMenu(); + } } diff --git a/src/app/common/components/autocomplete/text-input-autocomplete.module.ts b/src/app/common/components/autocomplete/text-input-autocomplete.module.ts index c7867ff850..7d2820b463 100644 --- a/src/app/common/components/autocomplete/text-input-autocomplete.module.ts +++ b/src/app/common/components/autocomplete/text-input-autocomplete.module.ts @@ -4,18 +4,21 @@ import { CommonModule } from '@angular/common'; import { TextInputAutocompleteDirective } from './text-input-autocomplete.directive'; import { TextInputAutocompleteContainerComponent } from './text-input-autocomplete-container.component'; import { TextInputAutocompleteMenuComponent } from './text-input-autocomplete-menu.component'; +import { PostsAutocompleteItemRendererComponent } from "./item-renderers/posts-autocomplete.component"; @NgModule({ declarations: [ TextInputAutocompleteDirective, TextInputAutocompleteContainerComponent, - TextInputAutocompleteMenuComponent + TextInputAutocompleteMenuComponent, + PostsAutocompleteItemRendererComponent, ], imports: [CommonModule], exports: [ TextInputAutocompleteDirective, TextInputAutocompleteContainerComponent, - TextInputAutocompleteMenuComponent + TextInputAutocompleteMenuComponent, + PostsAutocompleteItemRendererComponent, ], entryComponents: [TextInputAutocompleteMenuComponent] }) diff --git a/src/app/helpers/contenteditable-caret.ts b/src/app/helpers/contenteditable-caret.ts new file mode 100644 index 0000000000..94a7e137c6 --- /dev/null +++ b/src/app/helpers/contenteditable-caret.ts @@ -0,0 +1,58 @@ +// node_walk: walk the element tree, stop when func(node) returns false +function node_walk(node, func) { + var result = func(node); + for (node = node.firstChild; result !== false && node; node = node.nextSibling) + result = node_walk(node, func); + return result; +}; + +// getCaretPosition: return [start, end] as offsets to elem.textContent that +// correspond to the selected portion of text +// (if start == end, caret is at given position and no text is selected) +export function getContentEditableCaretCoordinates(elem) { + var sel: any = window.getSelection(); + var cum_length = [0, 0]; + + if (sel.anchorNode == elem) + cum_length = [sel.anchorOffset, sel.extentOffset]; + else { + var nodes_to_find = [sel.anchorNode, sel.extentNode]; + if (!elem.contains(sel.anchorNode) || !elem.contains(sel.extentNode)) + return undefined; + else { + var found: any = [0, 0]; + var i; + node_walk(elem, function (node) { + for (i = 0; i < 2; i++) { + if (node == nodes_to_find[i]) { + found[i] = true; + if (found[i == 0 ? 1 : 0]) + return false; // all done + } + } + + if (node.textContent && !node.firstChild) { + for (i = 0; i < 2; i++) { + if (!found[i]) + cum_length[i] += node.textContent.length; + } + } + }); + cum_length[0] += sel.anchorOffset; + cum_length[1] += sel.extentOffset; + } + } + let coordinates = { + start: cum_length[0], + end: cum_length[1], + }; + + if (cum_length[0] <= cum_length[1]) + return coordinates; + + // it's reversed + coordinates.start = cum_length[1]; + coordinates.end = cum_length[0]; + + return coordinates; +} diff --git a/src/app/modules/comments/comments.module.ts b/src/app/modules/comments/comments.module.ts index 9af11055d2..32fe7a8184 100644 --- a/src/app/modules/comments/comments.module.ts +++ b/src/app/modules/comments/comments.module.ts @@ -15,6 +15,7 @@ import { CommentPosterComponent } from './poster/poster.component'; import { CommentsTreeComponent } from './tree/tree.component'; import { CommentsThreadComponent } from './thread/thread.component'; import { CommentsService } from './comments.service'; +import { TextInputAutocompleteModule } from "../../common/components/autocomplete"; @NgModule({ imports: [ @@ -25,6 +26,7 @@ import { CommentsService } from './comments.service'; VideoModule, TranslateModule, ModalsModule, + TextInputAutocompleteModule, ], declarations: [ CommentsScrollDirective, diff --git a/src/app/modules/comments/list/list.component.scss b/src/app/modules/comments/list/list.component.scss index 897fc0f043..a304a67a82 100644 --- a/src/app/modules/comments/list/list.component.scss +++ b/src/app/modules/comments/list/list.component.scss @@ -245,6 +245,9 @@ minds-comments, m-comments__tree, .m-comment-wrapper { } } + .m-comments-composer form m-text-input--autocomplete-container { + width: 100%; + } .m-comments-composer form minds-textarea, .minds-editable-container textarea { width: 100%; @@ -423,6 +426,6 @@ minds-comments, m-comments__tree, .m-comment-wrapper { } .m-comment--poster .minds-body { - overflow: hidden; + overflow: visible; min-height: 50px; } diff --git a/src/app/modules/comments/poster/poster.component.html b/src/app/modules/comments/poster/poster.component.html index 205647c716..93b416b511 100644 --- a/src/app/modules/comments/poster/poster.component.html +++ b/src/app/modules/comments/poster/poster.component.html @@ -10,14 +10,28 @@ <div class="m-comments-composer"> <form (submit)="post($event)"> - <minds-textarea - #message="Textarea" - [(mModel)]="content" - [disabled]="(ascendingInProgress || descendingInProgress) && attachment.hasFile()" - (keyup)="getPostPreview(content)" - (keypress)="keypress($event)" - [placeholder]="conversation ? 'Enter your message' : 'Enter your comment'" - ></minds-textarea> + <ng-template #itemTemplate let-choice="choice" let-selectChoice="selectChoice"> + <m-post-autocomplete-item-renderer + [choice]="choice" + [selectChoice]="selectChoice" + ></m-post-autocomplete-item-renderer> + </ng-template> + <m-text-input--autocomplete-container> + <minds-textarea + #message="Textarea" + [(mModel)]="content" + [disabled]="(ascendingInProgress || descendingInProgress) && attachment.hasFile()" + (keyup)="getPostPreview(content)" + (keypress)="keypress($event)" + [placeholder]="conversation ? 'Enter your message' : 'Enter your comment'" + mTextInputAutocomplete + [findChoices]="suggestions.findSuggestions" + [getChoiceLabel]="suggestions.getChoiceLabel" + [itemTemplate]="itemTemplate" + [triggerCharacters]="['#', '@']" + ></minds-textarea> + </m-text-input--autocomplete-container> + </form> <div class="minds-comment-span mdl-color-text--red-500" *ngIf="!canPost && triedToPost"> diff --git a/src/app/modules/comments/poster/poster.component.ts b/src/app/modules/comments/poster/poster.component.ts index c835915d08..541b975b37 100644 --- a/src/app/modules/comments/poster/poster.component.ts +++ b/src/app/modules/comments/poster/poster.component.ts @@ -16,6 +16,8 @@ import { Upload } from '../../../services/api/upload'; import { AttachmentService } from '../../../services/attachment'; import { Textarea } from '../../../common/components/editors/textarea.component'; import { SocketsService } from '../../../services/sockets'; +import autobind from "../../../helpers/autobind"; +import { AutocompleteSuggestionsService } from "../../suggestions/services/autocomplete-suggestions.service"; @Component({ selector: 'm-comment__poster', @@ -53,6 +55,7 @@ export class CommentPosterComponent { public client: Client, public attachment: AttachmentService, public sockets: SocketsService, + public suggestions: AutocompleteSuggestionsService, private renderer: Renderer, private cd: ChangeDetectorRef ) { diff --git a/src/app/modules/newsfeed/poster/poster.component.html b/src/app/modules/newsfeed/poster/poster.component.html index 08d7ff30b1..60bcf61308 100644 --- a/src/app/modules/newsfeed/poster/poster.component.html +++ b/src/app/modules/newsfeed/poster/poster.component.html @@ -2,27 +2,10 @@ <div class="mdl-card__supporting-text"> <form (submit)="post()"> <ng-template #itemTemplate let-choice="choice" let-selectChoice="selectChoice"> - <ng-container *ngIf="choice?.type == 'user'; else hashtagBlock"> - <a - href="javascript:;" - (click)="selectChoice.next(choice.username)" - > - <img - class="m-poster__userSuggestionAvatar mdl-shadow--2dp" - [src]="minds.cdn_url + 'icon/' + choice.guid + '/medium/' + choice.icontime"> - {{ choice.username }} - </a> - </ng-container> - - <ng-template #hashtagBlock> - <a - href="javascript:;" - (click)="selectChoice.next(choice)" - > - #{{ choice }} - </a> - </ng-template> - + <m-post-autocomplete-item-renderer + [choice]="choice" + [selectChoice]="selectChoice" + ></m-post-autocomplete-item-renderer> </ng-template> <m-text-input--autocomplete-container> @@ -39,8 +22,8 @@ i18n-placeholder="@@MINDS__POSTER__SPEAK_YOUR_MIND" [autoGrow] mTextInputAutocomplete - [findChoices]="findSuggestions" - [getChoiceLabel]="getChoiceLabel" + [findChoices]="suggestions.findSuggestions" + [getChoiceLabel]="suggestions.getChoiceLabel" [itemTemplate]="itemTemplate" [triggerCharacters]="['#', '@']" ></textarea> diff --git a/src/app/modules/newsfeed/poster/poster.component.scss b/src/app/modules/newsfeed/poster/poster.component.scss index b19b91dcce..a949c51f33 100644 --- a/src/app/modules/newsfeed/poster/poster.component.scss +++ b/src/app/modules/newsfeed/poster/poster.component.scss @@ -257,11 +257,6 @@ m-hashtags-selector { } .m-poster { - .m-poster__userSuggestionAvatar { - margin-right: 4px; - height: 32px; - border-radius: 50%; - } .m-poster__ActionBar { display: flex; flex-direction: row; diff --git a/src/app/modules/newsfeed/poster/poster.component.ts b/src/app/modules/newsfeed/poster/poster.component.ts index b1541c0b27..c198e1795e 100644 --- a/src/app/modules/newsfeed/poster/poster.component.ts +++ b/src/app/modules/newsfeed/poster/poster.component.ts @@ -11,6 +11,7 @@ import { Subject, Subscription } from "rxjs"; import { debounceTime } from "rxjs/operators"; import { Router } from "@angular/router"; import { InMemoryStorageService } from "../../../services/in-memory-storage.service"; +import { AutocompleteSuggestionsService } from "../../suggestions/services/autocomplete-suggestions.service"; @Component({ moduleId: module.id, @@ -60,6 +61,7 @@ export class PosterComponent { public client: Client, public upload: Upload, public attachment: AttachmentService, + public suggestions: AutocompleteSuggestionsService, protected elementRef: ElementRef, protected router: Router, protected inMemoryStorageService: InMemoryStorageService @@ -259,36 +261,6 @@ export class PosterComponent { this.attachment.preview(message.value); } - @autobind() - async findSuggestions(searchText: string, triggerCharacter: string) { - if (searchText == '') - return; - - let url = 'api/v2/search/suggest'; - - if (triggerCharacter === '#') { - url += '/tags'; - } - const response: any = await this.client.get(url, { q: searchText }); - - let result; - switch (triggerCharacter) { - case '#': - result = response.tags.filter(item => item.toLowerCase().includes(searchText.toLowerCase())); - break; - case '@': - result = response.entities - .filter(item => item.username.toLowerCase().includes(searchText.toLowerCase())); - break; - } - - return result.slice(0,5); - } - - getChoiceLabel(text: string, triggerCharacter: string) { - return `${triggerCharacter}${text}`; - } - createBlog() { if (this.meta && this.meta.message) { const shouldNavigate = confirm(`Are you sure? The content will be moved to the blog editor.`); diff --git a/src/app/modules/suggestions/services/autocomplete-suggestions.service.ts b/src/app/modules/suggestions/services/autocomplete-suggestions.service.ts new file mode 100644 index 0000000000..afa92f3108 --- /dev/null +++ b/src/app/modules/suggestions/services/autocomplete-suggestions.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; +import { Client } from "../../../services/api/client"; +import autobind from "../../../helpers/autobind"; + +@Injectable() +export class AutocompleteSuggestionsService { + + constructor(private client: Client) { + } + + @autobind() + async findSuggestions(searchText: string, triggerCharacter: string) { + if (searchText == '') + return; + + let url = 'api/v2/search/suggest'; + + if (triggerCharacter === '#') { + url += '/tags'; + } + const response: any = await this.client.get(url, { q: searchText }); + + let result; + switch (triggerCharacter) { + case '#': + result = response.tags.filter(item => item.toLowerCase().includes(searchText.toLowerCase())); + break; + case '@': + result = response.entities + .filter(item => item.username.toLowerCase().includes(searchText.toLowerCase())); + break; + } + + return result.slice(0,5); + } + + getChoiceLabel(text: string, triggerCharacter: string) { + return `${triggerCharacter}${text}`; + } +} diff --git a/src/app/modules/suggestions/suggestions.module.ts b/src/app/modules/suggestions/suggestions.module.ts index df4a947b96..bbaa1b24e5 100644 --- a/src/app/modules/suggestions/suggestions.module.ts +++ b/src/app/modules/suggestions/suggestions.module.ts @@ -7,6 +7,7 @@ import { LegacyModule } from '../legacy/legacy.module'; import { CommonModule } from '../../common/common.module'; import { SuggestionsSidebar } from './channel/sidebar.component'; import { GroupSuggestionsSidebarComponent } from "./groups/sidebar.component"; +import { AutocompleteSuggestionsService } from "./services/autocomplete-suggestions.service"; @NgModule({ imports: [ @@ -25,6 +26,9 @@ import { GroupSuggestionsSidebarComponent } from "./groups/sidebar.component"; SuggestionsSidebar, GroupSuggestionsSidebarComponent, ], + providers: [ + AutocompleteSuggestionsService, + ] }) export class SuggestionsModule { } -- GitLab