-
-
-
Hello, {{ title }}
-
Congratulations! Your app is running. 🎉
-
-
-
-
- @for (item of [
- { title: 'Explore the Docs', link: 'https://angular.dev' },
- { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
- { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
- { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
- { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
- ]; track item.title) {
-
- {{ item.title }}
-
-
- }
-
-
-
+
+ Textbox:
+
+
+ Textarea
+
+
-
-
-
-
-
-
-
-
-
-
+ Enter your comments here:
+
+
+
+
+ Textarea
+
+
+
+
+
Comment Value: {{parseComment(form)}}
+
+
\ No newline at end of file
diff --git a/src/app/app.component.scss b/src/app/app.component.scss
index e69de29..9f5f3d0 100644
--- a/src/app/app.component.scss
+++ b/src/app/app.component.scss
@@ -0,0 +1,7 @@
+div {
+ padding: 2rem;
+}
+
+.box {
+ width: 100%;
+}
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index 187900a..3b39e6d 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -1,12 +1,26 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
+import { CommentBoxComponent } from "./comment-box/comment-box.component";
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-root',
- imports: [RouterOutlet],
+ imports: [RouterOutlet, CommentBoxComponent, MatFormFieldModule, MatInputModule, ReactiveFormsModule],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent {
title = 'material-mentions';
+
+ selectionList = ["Franz Kaiser", "Melanie Ochse", "Peter Pfeifer", "Hans Wurst", "Hans Ochse", "Max Hintern", "Hansi Hinterseer", "Gustav Gans"];
+
+ form = new FormControl("fdaf
@Franz Kaiser
");
+ test = '';
+
+ parseComment(comment: FormControl): any {
+ if (comment.value)
+ return comment.value
+ }
}
diff --git a/src/app/app.config.ts b/src/app/app.config.ts
index a1e7d6f..96116fd 100644
--- a/src/app/app.config.ts
+++ b/src/app/app.config.ts
@@ -2,7 +2,8 @@ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
+import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
export const appConfig: ApplicationConfig = {
- providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)]
+ providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideAnimationsAsync()]
};
diff --git a/src/app/comment-box/comment-box.component.html b/src/app/comment-box/comment-box.component.html
new file mode 100644
index 0000000..f4fed6c
--- /dev/null
+++ b/src/app/comment-box/comment-box.component.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app/comment-box/comment-box.component.scss b/src/app/comment-box/comment-box.component.scss
new file mode 100644
index 0000000..da1f591
--- /dev/null
+++ b/src/app/comment-box/comment-box.component.scss
@@ -0,0 +1,26 @@
+:host ::ng-deep .comment-box {
+ height: 4rem;
+
+ overflow-y: scroll;
+
+ &:focus {
+ outline: none;
+ }
+
+ .mention {
+ display: inline-block;
+ position: relative;
+
+ &:not(.ignore) {
+ color: blue;
+ background-color: transparent;
+ font-style: italic;
+ font-size: 0.8rem;
+ font-weight: bold;
+ }
+ }
+}
+
+:host.floating span {
+ opacity: 1;
+}
diff --git a/src/app/comment-box/comment-box.component.spec.ts b/src/app/comment-box/comment-box.component.spec.ts
new file mode 100644
index 0000000..51d1dda
--- /dev/null
+++ b/src/app/comment-box/comment-box.component.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { CommentBoxComponent } from './comment-box.component';
+
+describe('CommentBoxComponent', () => {
+ let component: CommentBoxComponent;
+ let fixture: ComponentFixture
;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [CommentBoxComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(CommentBoxComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/comment-box/comment-box.component.ts b/src/app/comment-box/comment-box.component.ts
new file mode 100644
index 0000000..0a017b3
--- /dev/null
+++ b/src/app/comment-box/comment-box.component.ts
@@ -0,0 +1,312 @@
+import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion';
+import { Component, ElementRef, HostBinding, Input, Optional, Self, ViewChild, ViewEncapsulation } from '@angular/core';
+import { NgControl, ControlValueAccessor } from '@angular/forms';
+import { MatFormFieldControl } from '@angular/material/form-field';
+import { MatMenuModule } from '@angular/material/menu';
+import { Subject } from 'rxjs';
+
+import { OverlayModule } from '@angular/cdk/overlay';
+import { DOWN_ARROW, ENTER, ESCAPE, TAB, UP_ARROW } from '@angular/cdk/keycodes';
+import { Cursor, MentionHandleResult } from './cursor';
+
+@Component({
+ selector: 'app-comment-box',
+ imports: [MatMenuModule, OverlayModule],
+ providers: [{ provide: MatFormFieldControl, useExisting: CommentBoxComponent }],
+ templateUrl: './comment-box.component.html',
+ styleUrl: './comment-box.component.scss',
+})
+export class CommentBoxComponent implements MatFormFieldControl, ControlValueAccessor {
+
+ constructor(@Optional() @Self() public ngControl: NgControl) {
+ // Replace the provider from above with this.
+ if (this.ngControl != null) {
+ // Setting the value accessor directly (instead of using
+ // the providers) to avoid running into a circular import.
+ this.ngControl.valueAccessor = this;
+ }
+ }
+
+ ngAfterViewInit(): void {
+ if (this.commentText !== '') {
+ this._commentInputRef.nativeElement.innerHTML = this.commentText;
+ this.onChange(this.commentText);
+ this.stateChanges.next()
+ }
+ }
+
+ ngOnDestroy() {
+ this.stateChanges.complete();
+ }
+
+ // ==================================================
+ // => Everything related to custom form field control
+ // ==================================================
+ static nextId = 0;
+ @HostBinding() id = `comment-with-mentions-${CommentBoxComponent.nextId++}`;
+ @HostBinding('class.floating')
+
+ @Input('aria-describedby') userAriaDescribedBy: string = '';
+ @Input()
+ get value(): string | null {
+ return this.commentText;
+ }
+ set value(text: string | null) {
+ this.commentText = text ?? '';
+ }
+ @Input()
+ get placeholder() {
+ return this._placeholder;
+ }
+ set placeholder(plh) {
+ this._placeholder = plh;
+ this.stateChanges.next();
+ }
+ get shouldLabelFloat() {
+ return this.focused || !this.empty;
+ }
+ @Input()
+ get required(): boolean {
+ return this._required;
+ }
+ set required(req: BooleanInput) {
+ this._required = coerceBooleanProperty(req);
+ this.stateChanges.next();
+ }
+ @Input()
+ get disabled(): boolean { return this._disabled; }
+ set disabled(value: BooleanInput) {
+ this._disabled = coerceBooleanProperty(value);
+ this.stateChanges.next();
+ }
+
+ commentText: string = '';
+ controlType = 'comments-input';
+ stateChanges = new Subject();
+ private _placeholder: string = '';
+ private _required = false;
+ private _disabled = false;
+ focused = false;
+ touched = false
+
+ onChange = (text: string) => { };
+ onTouched = () => { };
+
+ writeValue(comment: string): void {
+ this.commentText = comment;
+ // throw new Error('Method not implemented.');
+ }
+ registerOnChange(onChange: any): void {
+ this.onChange = onChange;
+ // throw new Error('Method not implemented.');
+ }
+ registerOnTouched(onTouched: any): void {
+ this.onTouched = onTouched;
+ // throw new Error('Method not implemented.');
+ }
+ setDisabledState?(isDisabled: boolean): void {
+ this.disabled = isDisabled;
+ }
+
+ onFocusIn(event: FocusEvent) {
+ if (!this.focused) {
+ this.focused = true;
+ this.stateChanges.next();
+ }
+ }
+
+ onFocusOut(event: FocusEvent) {
+ if (!this._commentInputRef.nativeElement.contains(event.relatedTarget as Element)) {
+ this.commentText = JSON.stringify(this._commentInputRef.nativeElement.innerHTML);
+ this.touched = true;
+ this.focused = false;
+ this.onTouched();
+ this.onChange(this.commentText);
+ this.isOpen = false;
+ this.stateChanges.next();
+ }
+ }
+
+ setDescribedByIds(ids: string[]) {
+ const controlElement = this._commentInputRef?.nativeElement;
+ controlElement?.setAttribute('aria-describedby', ids.join(' '));
+ }
+
+ onContainerClick(event: MouseEvent) {
+ this._commentInputRef.nativeElement.focus();
+ }
+
+ get empty(): boolean {
+ if (this._commentInputRef)
+ return this._commentInputRef.nativeElement.textContent === ''
+ else
+ return true;
+ }
+
+ get errorState(): boolean {
+ return false;
+ }
+
+
+ // ==================================================
+ // => Everything related to custom form field control
+ // ==================================================
+
+ @ViewChild('commentInput') _commentInputRef!: ElementRef;
+ @ViewChild('menuContent') _menuContent!: ElementRef;
+
+ @Input() mentionListItems: string[] = [];
+
+ selectedDropdownItemIndex = 0;
+ menuOffsetX = 0;
+ menuOffsetY = 0;
+ menuOpenAboveYShift = 0;
+
+ isOpen = false;
+ filteredSelectionList: string[] = [];
+
+ handleKeyEvent(event: KeyboardEvent) {
+ if (this.isOpen) {
+ switch (event.keyCode) {
+ case DOWN_ARROW:
+ this.activateNextItem();
+ event.preventDefault();
+ break;
+ case UP_ARROW:
+ this.activatePreviousElement();
+ event.preventDefault();
+ break;
+ case ESCAPE:
+ this.removeFocusedMention(event.target as HTMLInputElement);
+ event.preventDefault();
+ break;
+ case ENTER || TAB:
+ this.addMention(event.target as HTMLInputElement);
+ this.handleTextChangedEvent(event, true, 1);
+ event.preventDefault();
+ break;
+ }
+ }
+ }
+
+ handleTextChangedEvent(event: any, skipPositionGetting: boolean = false, offset: number = 0) {
+ event.preventDefault();
+
+ let htmlElement = (event.target as HTMLInputElement);
+ if (!skipPositionGetting)
+ offset = Cursor.getPosition(htmlElement);
+
+ // check for new mention Element starters
+ let result = this.handleMentionElements(htmlElement.innerHTML);
+ htmlElement.innerHTML = result.innerHtml;
+
+ if (result.newOffset)
+ offset = result.newOffset;
+
+ // open/close menu
+ let focusedMentionElement = htmlElement.querySelector(`.mention.${Cursor.focusedClassName}`);
+ if (focusedMentionElement) {
+ // set offset for menu
+ this.menuOffsetX = focusedMentionElement.offsetLeft;
+ this.menuOffsetY = focusedMentionElement.offsetTop;
+ this.menuOpenAboveYShift = focusedMentionElement.scrollHeight;
+
+ // filter menu entries
+ let searchQuery = focusedMentionElement.textContent?.substring(1);
+ this.filteredSelectionList = this.mentionListItems.filter((element) => element.toLocaleLowerCase().includes(searchQuery?.toLocaleLowerCase() ?? ""))
+
+ this.isOpen = true;
+ } else {
+ this.isOpen = false;
+ this.selectedDropdownItemIndex = 0;
+ }
+
+ Cursor.setCurrentCursorPosition(offset, htmlElement);
+ }
+
+ handleMentionElements(innerHtml: string): MentionHandleResult {
+ let newOffset = null;
+ innerHtml = innerHtml.replace(/(?|\w))@\w*/g, (match) => {
+ newOffset = match.length;
+ return `${match}
`;
+ });
+
+ // remove old focus classes if match was found
+ if (newOffset) {
+ innerHtml = innerHtml.replaceAll(/class="focused"/g, "");
+ }
+
+ return { newOffset, innerHtml };
+ }
+
+ activateNextItem() {
+ // adjust scrollable-menu offset if the next item is out of view
+ let listEl: HTMLElement = this._menuContent.nativeElement;
+ let activeEl = listEl.getElementsByClassName('active').item(0);
+ if (activeEl) {
+ let nextItemEl: HTMLElement = activeEl.nextSibling;
+ if (nextItemEl && nextItemEl.nodeName === "DIV") {
+ let nextLiRect: ClientRect = nextItemEl.getBoundingClientRect();
+ if (nextLiRect.bottom > listEl.getBoundingClientRect().bottom) {
+ listEl.scrollTop = nextItemEl.offsetTop + nextLiRect.height - listEl.clientHeight;
+ }
+ }
+ }
+
+ if (this.selectedDropdownItemIndex < this.mentionListItems.length - 1)
+ this.selectedDropdownItemIndex += 1;
+ else {
+ this.selectedDropdownItemIndex = 0;
+ this._menuContent.nativeElement.scrollTop = 0;
+ }
+ }
+
+ activatePreviousElement() {
+ // adjust the scrollable-menu offset if the previous item is out of view
+ let listEl: HTMLElement = this._menuContent.nativeElement;
+ let activeEl = listEl.getElementsByClassName('active').item(0);
+ if (activeEl) {
+ let prevItemEl: HTMLElement = activeEl.previousSibling;
+ if (prevItemEl && prevItemEl.nodeName == "DIV") {
+ let prevLiRect: ClientRect = prevItemEl.getBoundingClientRect();
+ if (prevLiRect.top < listEl.getBoundingClientRect().top) {
+ listEl.scrollTop = prevItemEl.offsetTop;
+ }
+ }
+ }
+
+ if (this.selectedDropdownItemIndex > 0)
+ this.selectedDropdownItemIndex -= 1;
+ else {
+ this.selectedDropdownItemIndex = this.mentionListItems.length - 1;
+ this._menuContent.nativeElement.scrollTop = listEl.scrollHeight;
+ }
+ }
+
+ addMention(element: HTMLElement) {
+ let focusedMention = element.querySelector(`.mention.${Cursor.focusedClassName}`);
+ let focusedElements = element.querySelectorAll(Cursor.focusedClassName);
+ if (focusedMention) {
+ // set text and remove ignore to style mention
+ focusedMention.textContent = `@${this.mentionListItems[this.selectedDropdownItemIndex]}`;
+ focusedMention.classList.remove(Cursor.focusedClassName);
+ focusedMention.classList.remove("ignore")
+
+ // remove all old focused classes
+ for (let node of focusedElements) {
+ node.classList.remove(Cursor.focusedClassName);
+ }
+ // create new span next to mention to move cursor there
+ let element = document.createElement("span");
+ element.classList.add(Cursor.focusedClassName);
+ element.innerHTML = " ";
+ focusedMention.insertAdjacentElement("afterend", element);
+ }
+ }
+
+ removeFocusedMention(element: HTMLElement) {
+ let focused = element.querySelector(`.mention.${Cursor.focusedClassName}`);
+ if (focused)
+ focused.remove();
+ }
+}
\ No newline at end of file
diff --git a/src/app/comment-box/cursor.ts b/src/app/comment-box/cursor.ts
new file mode 100644
index 0000000..f408a99
--- /dev/null
+++ b/src/app/comment-box/cursor.ts
@@ -0,0 +1,51 @@
+
+export interface MentionHandleResult { newOffset: number | null, innerHtml: string };
+
+export class Cursor {
+ static focusedClassName = "focused";
+
+ static getPosition(element: HTMLElement): number {
+ // remove all old focused classes
+ for (let node of element.getElementsByClassName(this.focusedClassName)) {
+ node.classList.remove(this.focusedClassName);
+ }
+
+ // get selection and initialize offset
+ let selection = window.getSelection();
+ let offset = -1;
+
+ // get currently focused node element and set offset
+ if (selection?.focusNode) {
+ let node = selection.focusNode;
+ offset = selection.focusOffset;
+
+ // go to parent if node is not an HTML Element
+ while (!node.classList) {
+ node = node.parentElement!;
+ }
+
+ // mark node as focused
+ node.classList?.add(this.focusedClassName);
+ }
+
+ return offset;
+ }
+
+ static setCurrentCursorPosition(chars: number, element: HTMLElement) {
+ let node: any | null = element.getElementsByClassName("focused")[0];
+
+ if (!node)
+ node = element;
+
+ let range = document.createRange()
+ let sel = window.getSelection()
+
+ if (node && node.firstChild) {
+ range.setStart(node.firstChild, chars)
+ range.collapse(true)
+
+ sel?.removeAllRanges()
+ sel?.addRange(range)
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/index.html b/src/index.html
index 80e5701..7d0a85f 100644
--- a/src/index.html
+++ b/src/index.html
@@ -6,8 +6,10 @@
+
+
-
+
diff --git a/src/styles.scss b/src/styles.scss
index 90d4ee0..7e7239a 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -1 +1,4 @@
/* You can add global styles to this file, and also import other style files */
+
+html, body { height: 100%; }
+body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }