diff --git a/angular.json b/angular.json index c345acf..357b559 100644 --- a/angular.json +++ b/angular.json @@ -32,6 +32,7 @@ } ], "styles": [ + "@angular/material/prebuilt-themes/azure-blue.css", "src/styles.scss" ], "scripts": [] @@ -91,6 +92,7 @@ } ], "styles": [ + "@angular/material/prebuilt-themes/azure-blue.css", "src/styles.scss" ], "scripts": [] diff --git a/package-lock.json b/package-lock.json index c77e743..51a5024 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "0.0.0", "dependencies": { "@angular/animations": "^19.0.0", + "@angular/cdk": "^19.0.1", "@angular/common": "^19.0.0", "@angular/compiler": "^19.0.0", "@angular/core": "^19.0.0", "@angular/forms": "^19.0.0", + "@angular/material": "^19.0.1", "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", @@ -349,6 +351,23 @@ } } }, + "node_modules/@angular/cdk": { + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.0.1.tgz", + "integrity": "sha512-dIqYBQISvxlpXIU10625rURPjniQV1emXbFF6wAEE48iqx9mm9WZ11KZU4heqA3qp/betZYcVY2Hwc7fLKp4Uw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "optionalDependencies": { + "parse5": "^7.1.2" + }, + "peerDependencies": { + "@angular/common": "^19.0.0 || ^20.0.0", + "@angular/core": "^19.0.0 || ^20.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/cli": { "version": "19.0.2", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.0.2.tgz", @@ -482,6 +501,24 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@angular/material": { + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-19.0.1.tgz", + "integrity": "sha512-pAZ+cgBUAJjXmwAY4u1NXuxcxJKHts0s7ZNpf6JGUu+yWArLOc/BwFTDO9Htzz2E82eMH417d1ny4fpYwdgIZg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/animations": "^19.0.0 || ^20.0.0", + "@angular/cdk": "19.0.1", + "@angular/common": "^19.0.0 || ^20.0.0", + "@angular/core": "^19.0.0 || ^20.0.0", + "@angular/forms": "^19.0.0 || ^20.0.0", + "@angular/platform-browser": "^19.0.0 || ^20.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/platform-browser": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.0.1.tgz", @@ -7165,7 +7202,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -11369,7 +11406,7 @@ "version": "7.2.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "entities": "^4.5.0" diff --git a/package.json b/package.json index fde0b66..44b57ec 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,12 @@ "private": true, "dependencies": { "@angular/animations": "^19.0.0", + "@angular/cdk": "^19.0.1", "@angular/common": "^19.0.0", "@angular/compiler": "^19.0.0", "@angular/core": "^19.0.0", "@angular/forms": "^19.0.0", + "@angular/material": "^19.0.1", "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", @@ -35,4 +37,4 @@ "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~5.6.2" } -} +} \ No newline at end of file diff --git a/src/app/app.component.html b/src/app/app.component.html index 36093e1..212aabf 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,336 +1,23 @@ - - - - - - - - - - - -
-
-
- -

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 @@ +
+
+ + + + + + + +
+
+ @for (item of filteredSelectionList; track item; let index_ = $index) { +
{{item}}
+ } +
+
+
\ 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; }