diff --git a/projects/voicewave-angular/src/lib/voicewave-angular.component.html b/projects/voicewave-angular/src/lib/voicewave-angular.component.html index 39ae8ae..769d8ba 100644 --- a/projects/voicewave-angular/src/lib/voicewave-angular.component.html +++ b/projects/voicewave-angular/src/lib/voicewave-angular.component.html @@ -1,73 +1,22 @@ - + + + + + + diff --git a/projects/voicewave-angular/src/lib/voicewave-angular.component.scss b/projects/voicewave-angular/src/lib/voicewave-angular.component.scss index e69de29..986a9cc 100644 --- a/projects/voicewave-angular/src/lib/voicewave-angular.component.scss +++ b/projects/voicewave-angular/src/lib/voicewave-angular.component.scss @@ -0,0 +1,90 @@ +.voicewave { + align-items: center; + background-color: var(--main-bg-color-primary); + bottom: 0; + box-shadow: 0 4px 16px rgba(0,0,0,0.2); + display: flex; + font-family: arial,sans-serif; + font-size: 14px; + height: auto; + justify-content: center; + left: 0; + opacity: 0; + padding: 35px; + pointer-events: none; + position: absolute; + right: 0; + top: 0; + transition: .5s all; + user-select: none; + z-index: 99; + &.active { + opacity: 1; + pointer-events: all; + p { + opacity: 1; + transform: translateX(0); + } + .btn-voice { transform: scale(1); } + } + p { + font-size: 22px; + margin: 0; + opacity: 0; + padding: 0; + transform: translateX(100px); + transition: 1s all; + } + .exit { + &:before { + content: ''; + display: block; + width: 100%; + height: 100%; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + } + } +} + +.btn-voice { + --mic-volume-size: 150px; + align-items: center; + background-color: rgba(var(--main-color-rgb), .05); + border-radius: 50%; + border: 0; + box-shadow: 0 0 0 1px rgba(#fff, .6), 0 10px 20px rgba(var(--main-color-rgb), .1); + cursor: pointer; + display: flex; + height: var(--mic-volume-size); + justify-content: center; + margin-left: 20px; + opacity: .5; + outline: none; + transition: .4s all; + transform: scale(0); + user-select: none; + width: var(--mic-volume-size); + min-width: var(--mic-volume-size); + &.active { + animation: 1s voiceAnimation infinite alternate; + opacity: 1; + svg { fill: #ea4335; } + } + &:hover { + transform: scale(1.1); + } + svg { + height: 50%; + width: 50%; + fill: var(--main-color) + } +} + +@keyframes voiceAnimation { + 0% { box-shadow: 0 0 0 1px rgba(#fff, .6), 0 10px 20px rgba(var(--main-color-rgb), .1), 0 0 10px rgba(var(--main-color-rgb), .1); } + 100% { box-shadow: 0 0 0 1px rgba(#fff, .6), 0 10px 20px rgba(var(--main-color-rgb), .1), 0 0 0 30px rgba(var(--main-color-rgb), .1);} +} diff --git a/projects/voicewave-angular/src/lib/voicewave-angular.component.spec.ts b/projects/voicewave-angular/src/lib/voicewave-angular.component.spec.ts index c03a7ca..93a325e 100644 --- a/projects/voicewave-angular/src/lib/voicewave-angular.component.spec.ts +++ b/projects/voicewave-angular/src/lib/voicewave-angular.component.spec.ts @@ -1,179 +1,24 @@ -import { - ComponentFixture, - TestBed, - fakeAsync, - tick, -} from '@angular/core/testing'; -import { VoiceWaveAngularComponent } from '../voicewave-angular.component'; -import { By } from '@angular/platform-browser'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; -describe('VoiceWaveAngularComponent', () => { - let component: VoiceWaveAngularComponent; - let fixture: ComponentFixture; +import { VoiceWave } from './voicewave-angular.component'; + +describe('VoiceWave', () => { + let component: VoiceWave; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [VoiceWaveAngularComponent], + declarations: [VoiceWave], }).compileComponents(); + }); - fixture = TestBed.createComponent(VoiceWaveAngularComponent); + beforeEach(() => { + fixture = TestBed.createComponent(VoiceWave); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create the component', () => { + it('should create', () => { expect(component).toBeTruthy(); }); - - it('should render title and subtitle', () => { - component.title = 'Test Title'; - component.subtitle = 'Test Subtitle'; - fixture.detectChanges(); - - const titleElement = fixture.debugElement.query( - By.css('.up-window-title') - ).nativeElement; - const subtitleElement = fixture.debugElement.query( - By.css('.up-window-subtitle') - ).nativeElement; - - expect(titleElement.textContent).toContain('Test Title'); - expect(subtitleElement.textContent).toContain('Test Subtitle'); - }); - - it('should append modal to body when isOpen is true', fakeAsync(() => { - component.isOpen.set(false); - fixture.detectChanges(); - expect(document.body.querySelector('.up-window')).toBeNull(); - - component.isOpen.set(true); - fixture.detectChanges(); - tick(100); - expect(document.body.querySelector('.up-window')).toBeTruthy(); - - component.isOpen.set(false); - fixture.detectChanges(); - tick(400); - expect(document.body.querySelector('.up-window')).toBeNull(); - })); - - it('should not reappend modal if it is already in the body', fakeAsync(() => { - component.isOpen.set(true); - fixture.detectChanges(); - tick(600); - - spyOn(component, 'addModalToBody').and.callThrough(); - - component.addModalToBody(); - fixture.detectChanges(); - - expect(component.addModalToBody).toHaveBeenCalledTimes(1); - expect(document.body.querySelectorAll('.up-window').length).toBe(1); - })); - - it('should apply the correct animation class when opening and closing the window', fakeAsync(() => { - component.animation = 'slide'; - - component.isOpen.set(true); - component.openingAnimation = true; - component.closingAnimation = false; - fixture.detectChanges(); - - tick(100); - - let windowElement = fixture.debugElement.query(By.css('.up-window')); - - expect(windowElement.classes['slide']).toBeTrue(); - - component.closingAnimation = true; - component.openingAnimation = false; - component.isOpen.set(false); - fixture.detectChanges(); - - tick(600); - - windowElement = fixture.debugElement.query(By.css('.up-window')); - - expect(windowElement.classes['slide-out']).toBeTrue(); - expect(component.isOpen()).toBeFalse(); - })); - - it('should trigger onConfirm and onCancel when buttons are clicked', async () => { - spyOn(component, 'onConfirm').and.callThrough(); - spyOn(component, 'onCancel').and.callThrough(); - - component.isOpen.set(true); - fixture.detectChanges(); - - await fixture.whenStable(); - fixture.detectChanges(); - - const confirmButton = fixture.debugElement.query(By.css('.btn-confirm')); - const cancelButton = fixture.debugElement.query(By.css('.btn-cancel')); - - expect(confirmButton).toBeTruthy(); - expect(cancelButton).toBeTruthy(); - - confirmButton.nativeElement.click(); - await fixture.whenStable(); - expect(component.onConfirm).toHaveBeenCalled(); - expect(component.isOpen()).toBeFalse(); - - component.isOpen.set(true); - fixture.detectChanges(); - - await fixture.whenStable(); - cancelButton.nativeElement.click(); - await fixture.whenStable(); - expect(component.onCancel).toHaveBeenCalled(); - expect(component.isOpen()).toBeFalse(); - }); - - it('should apply custom class from @Input', () => { - component.class = 'custom-class'; - fixture.detectChanges(); - - const windowElement = fixture.debugElement.query(By.css('.up-window')); - expect(windowElement.classes['custom-class']).toBeTruthy(); - }); - - it('should set isOpen to true when openWindow is called', () => { - component.isOpen.set(true); - expect(component.isOpen()).toBeTrue(); - }); - - it('should set isOpen to false after closeWindow is called', fakeAsync(() => { - component.isOpen.set(true); - fixture.detectChanges(); - - component.isOpen.set(false); - fixture.detectChanges(); - - tick(600); - expect(component.isOpen()).toBeFalse(); - })); - - it('should not show confirm and cancel buttons if hiddenActions is true', () => { - component.hiddenActions = true; - component.isOpen.set(true); - fixture.detectChanges(); - - const confirmButton = fixture.debugElement.query(By.css('.btn-confirm')); - const cancelButton = fixture.debugElement.query(By.css('.btn-cancel')); - - expect(confirmButton).toBeNull(); - expect(cancelButton).toBeNull(); - }); - - it('should show confirm and cancel buttons if hiddenActions is false', () => { - component.hiddenActions = false; - component.isOpen.set(true); - fixture.detectChanges(); - - const confirmButton = fixture.debugElement.query(By.css('.btn-confirm')); - const cancelButton = fixture.debugElement.query(By.css('.btn-cancel')); - - expect(confirmButton).toBeTruthy(); - expect(cancelButton).toBeTruthy(); - }); }); diff --git a/projects/voicewave-angular/src/lib/voicewave-angular.component.ts b/projects/voicewave-angular/src/lib/voicewave-angular.component.ts index bce325c..f0efcff 100644 --- a/projects/voicewave-angular/src/lib/voicewave-angular.component.ts +++ b/projects/voicewave-angular/src/lib/voicewave-angular.component.ts @@ -1,207 +1,129 @@ import { Component, - Input, OnInit, - ViewEncapsulation, - signal, - WritableSignal, + DoCheck, + Input, Output, EventEmitter, - OnDestroy, - ElementRef, - ViewChild, - effect, ChangeDetectorRef, } from '@angular/core'; @Component({ - selector: 'voicewave-angular', - templateUrl: './voicewave-angular.component.html', - styleUrls: [ - './voicewave-angular.variables.scss', - './voicewave-angular.animation.scss', - './voicewave-angular.component.scss', - './voicewave-angular.btn.scss', - ], - encapsulation: ViewEncapsulation.None, + selector: 'voicewave', + templateUrl: './voice.component.html', + styleUrls: ['./voice.component.scss'], }) -export class VoiceWaveAngularComponent implements OnInit, OnDestroy { - @Input() title?: string; - @Input() subtitle?: string; - @Input() class: string | undefined; - @Input() isOpen: WritableSignal = signal(false); - @Input() drawer: 'bottom' | 'top' | 'left' | 'right' | '' = ''; - @Input() animation: string = this.drawer ? this.drawer : 'fade'; - @Input() restrictMode: boolean = false; - @Input() fullScreen: boolean = false; - @Input() blur: boolean = false; - @Input() grayscale: boolean = false; - @Input() hiddenActions: boolean = false; - @Input() confirmText: string = 'Confirm'; - @Input() cancelText: string = 'Cancel'; - @Input() confirmType: string = 'primary'; - @Input() cancelType: string = 'default'; - @Input() buttonAlignment: 'start' | 'end' | 'center' = 'end'; - @Input() onConfirmClick: () => void = () => this.onConfirm(); - @Input() onCancelClick: () => void = () => this.onCancel(); - - @Output() confirm = new EventEmitter(); - @Output() cancel = new EventEmitter(); - - closingAnimation: boolean = false; - openingAnimation: boolean = false; - shakeAnimation: boolean = false; - focusableElements!: NodeListOf; - firstFocusableElement!: HTMLElement; - lastFocusableElement!: HTMLElement; - - @ViewChild('modal') modal!: ElementRef; - - constructor(private cdr: ChangeDetectorRef) { - effect(() => { - if (this.isOpen()) { - this.addModalToBody(); - this.startOpeningAnimation(); - document.body.classList.add('no-scroll'); - } else { - this.startClosingAnimation(); - this.removeModalFromBody(); - document.body.classList.remove('no-scroll'); - } - }); - } +export class VoiceWave implements OnInit, DoCheck { + @Input() show: boolean = false; + @Output() updateVoice = new EventEmitter(); + final_transcript: string = ''; + recognizing: boolean = false; + ignore_onend: any; + recognition: any; + animationButton: boolean = false; + root = window.document; + + constructor(private _cdr: ChangeDetectorRef) {} ngOnInit(): void { - document.addEventListener('keydown', this.handleKeydown.bind(this)); - } - - ngAfterViewInit(): void { - if (this.modal) { - document.body.appendChild(this.modal.nativeElement); - - this.focusableElements = this.modal.nativeElement.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ); - if (this.focusableElements.length > 0) { - this.firstFocusableElement = this.focusableElements[0]; - this.lastFocusableElement = - this.focusableElements[this.focusableElements.length - 1]; - } - } - this.cdr.detectChanges(); + this.voiceSetup(); + this.show ? this.activeVoice() : this.desactiveVoice(); } - addModalToBody() { - if (this.modal && this.modal.nativeElement.parentNode !== document.body) { - document.body.appendChild(this.modal.nativeElement); - } + ngDoCheck(): void { + this.show ? this.activeVoice() : this.desactiveVoice(); } - removeModalFromBody() { - if (this.modal && this.modal.nativeElement.parentNode === document.body) { - document.body.removeChild(this.modal.nativeElement); - } + disableVoice() { + this.updateVoice.next(false); } - handleKeydown(event: KeyboardEvent) { - if (event.key === 'Escape' && this.isOpen() && !this.restrictMode) { - this.closeWindow(); - } else if (event.key === 'Escape' && this.isOpen() && this.restrictMode) { - this.triggerShakeAnimation(); + activeVoice() { + if (!this.recognizing) { + this.recognizing = true; + this.final_transcript = ''; + this.recognition.start(); } - - if (event.key === 'Tab' && this.isOpen()) { - this.trapFocus(event); - } - } - - triggerShakeAnimation() { - this.shakeAnimation = true; - setTimeout(() => { - this.shakeAnimation = false; - }, 300); } - trapFocus(event: KeyboardEvent) { - const isShiftPressed = event.shiftKey; - const activeElement = document.activeElement as HTMLElement; - - if (!isShiftPressed && activeElement === this.lastFocusableElement) { - event.preventDefault(); - this.firstFocusableElement.focus(); - } else if (isShiftPressed && activeElement === this.firstFocusableElement) { - event.preventDefault(); - this.lastFocusableElement.focus(); + desactiveVoice() { + if (this.recognizing) { + this.recognizing = false; + this.animationButton = false; + this.recognition.stop(); } } - ngOnDestroy(): void { - this.removeModalFromBody(); - document.removeEventListener('keydown', this.handleKeydown.bind(this)); - } - - hasFooterContent(): boolean { - const footerContent = this.modal?.nativeElement.querySelector('[footer]'); - return !!footerContent && footerContent.childNodes.length > 0; - } - - startOpeningAnimation() { - this.openingAnimation = true; - setTimeout(() => { - this.openingAnimation = false; - }, 400); - } - - startClosingAnimation() { - this.closingAnimation = true; - setTimeout(() => { - this.closingAnimation = false; - }, 400); - } - - closeWindow(from?: string) { - if (from == 'overlay' && this.restrictMode) { - this.triggerShakeAnimation(); + voiceSetup() { + if (!('webkitSpeechRecognition' in window)) { + console.log('atualize SpeechRecognition'); } else { - this.closingAnimation = true; - - setTimeout(() => { - this.isOpen.set(false); - this.closingAnimation = false; - }, 400); + this.recognition = new (window).webkitSpeechRecognition(); + + this.recognition.continuous = false; + this.recognition.interimResults = true; + + this.final_transcript = ''; + this.ignore_onend = false; + this.root.querySelector('.voicewave p').textContent = 'Ative o microfone'; + + this.recognition.onstart = () => { + this.recognizing = true; + this.root.querySelector('.voicewave p').textContent = 'Fale agora'; + this.animationButton = true; + console.log('onstart voice'); + this._cdr.detectChanges(); + }; + + this.recognition.onerror = (event: any) => { + this.animationButton = false; + if (event.error === 'no-speech') { + console.log('onerror voice no-speech'); + this.ignore_onend = true; + } + if (event.error === 'audio-capture') { + console.log('onerror audio-capture'); + this.ignore_onend = true; + } + if (event.error === 'not-allowed') { + this.root.querySelector('.voicewave p').textContent = + 'Ative o microfone'; + this.ignore_onend = true; + } + this._cdr.detectChanges(); + }; + + this.recognition.onend = () => { + this.recognizing = false; + if (this.ignore_onend) { + return; + } + if (!this.final_transcript) { + return; + } + this.root.querySelector('.voicewave .exit').click(); + this.root.querySelector('.voicewave p').textContent = ''; + }; + + this.recognition.onresult = (event: any) => { + var interim_transcript = ''; + for (var i = event.resultIndex; i < event.results.length; ++i) { + if (event.results[i].isFinal) { + this.final_transcript += event.results[i][0].transcript; + } else { + interim_transcript += event.results[i][0].transcript; + } + } + if (interim_transcript) { + this.root.querySelector('.voicewave p').textContent = + interim_transcript; + } + if (this.final_transcript) { + this.root.querySelector('body').removeAttribute('style'); + console.log(this.final_transcript); + this.recognition.stop(); + } + }; } } - - getClass() { - return { - ...(this.class ? { [this.class]: true } : {}), - [this.animation]: this.openingAnimation && !this.closingAnimation, - [`${this.animation}-out`]: this.closingAnimation, - [`drawer drawer-${this.drawer}`]: !!this.drawer, - shake: this.shakeAnimation, - fullscreen: this.fullScreen, - blur: this.blur, - grayscale: this.grayscale, - }; - } - - getButtonClass(type: string) { - const buttonClasses: { [key: string]: string } = { - primary: 'btn-primary', - secondary: 'btn-secondary', - danger: 'btn-danger', - }; - - return buttonClasses[type] || 'btn-default'; - } - - onCancel() { - this.cancel.emit(); - this.closeWindow(); - } - - onConfirm() { - this.confirm.emit(); - this.closeWindow(); - } }