Skip to content

Commit 11572ff

Browse files
Andrea Barbassoatarix83
authored andcommitted
Merged in UXP-126 (pull request #5)
UXP-126 Approved-by: Giuseppe Digilio
2 parents 1b394bf + a147819 commit 11572ff

9 files changed

Lines changed: 460 additions & 5 deletions

src/app/app.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
<ds-themed-root
2+
dsTextSelectTooltip
23
[shouldShowFullscreenLoader]="(isAuthBlocking$ | async) || (isThemeLoading$ | async)"
34
[shouldShowRouteLoader]="isRouteLoading$ | async"></ds-themed-root>

src/app/app.module.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { StoreDevModules } from '../config/store/devtools';
3232
import { RootModule } from './root.module';
3333
import { NuMarkdownModule } from '@ng-util/markdown';
3434
import { FooterModule } from './footer/footer.module';
35+
import { DirectivesModule } from './directives/directives.module';
3536

3637
export function getConfig() {
3738
return environment;
@@ -66,6 +67,7 @@ const IMPORTS = [
6667
StoreDevModules,
6768
EagerThemesModule,
6869
RootModule,
70+
DirectivesModule
6971
];
7072

7173
const PROVIDERS = [
@@ -120,10 +122,10 @@ const EXPORTS = [
120122
];
121123

122124
@NgModule({
123-
imports: [
124-
BrowserModule.withServerTransition({ appId: 'dspace-angular' }),
125-
...IMPORTS
126-
],
125+
imports: [
126+
BrowserModule.withServerTransition({appId: 'dspace-angular'}),
127+
...IMPORTS
128+
],
127129
providers: [
128130
...PROVIDERS
129131
],

src/app/directives/directives.module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import { RedirectDirective } from './redirect/redirect.directive';
33
import { NgModule } from '@angular/core';
44
import { MissingTranslationHandler, TranslateModule } from '@ngx-translate/core';
55
import { MissingTranslationHelper } from '../shared/translate/missing-translation.helper';
6+
import { TextSelectDirective } from './text-select/text-select.directive';
67

78
const DIRECTIVES = [
89
RedirectDirective,
9-
RedirectWithHrefDirective
10+
RedirectWithHrefDirective,
11+
TextSelectDirective,
1012
];
1113

1214
@NgModule({
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { TextSelectDirective } from './text-select.directive';
2+
import { ApplicationRef, ElementRef, NgZone } from '@angular/core';
3+
import { TestBed } from '@angular/core/testing';
4+
import { DOCUMENT } from '@angular/common';
5+
6+
describe('TextSelectDirective', () => {
7+
let directive: TextSelectDirective;
8+
let elementRef: ElementRef;
9+
let ngZone: NgZone;
10+
let appRef: ApplicationRef;
11+
12+
beforeEach(() => {
13+
TestBed.configureTestingModule({
14+
providers: [
15+
TextSelectDirective,
16+
{ provide: Document, useExisting: DOCUMENT },
17+
{ provide: ApplicationRef, useValue: { attachView: () => ({ rootNodes: [{}] }) } },
18+
{ provide: ElementRef, useValue: { nativeElement: document.createElement('div') } },
19+
{ provide: NgZone, useValue: new NgZone({}) },
20+
]
21+
});
22+
23+
directive = TestBed.inject(TextSelectDirective);
24+
elementRef = TestBed.inject(ElementRef);
25+
ngZone = TestBed.inject(NgZone);
26+
appRef = TestBed.inject(ApplicationRef);
27+
});
28+
29+
it('should create an instance', () => {
30+
expect(directive).toBeTruthy();
31+
});
32+
33+
it('should set up event listener on ngOnInit', () => {
34+
spyOn(elementRef.nativeElement, 'addEventListener');
35+
directive.ngOnInit();
36+
expect(elementRef.nativeElement.addEventListener).toHaveBeenCalledWith('mousedown', directive.handleMousedown, false);
37+
});
38+
39+
it('should remove event listener on ngOnDestroy', () => {
40+
spyOn(elementRef.nativeElement, 'removeEventListener');
41+
directive.ngOnDestroy();
42+
expect(elementRef.nativeElement.removeEventListener).toHaveBeenCalledWith('mousedown', directive.handleMousedown, false);
43+
});
44+
45+
it('should process selection correctly', () => {
46+
const selection = {
47+
rangeCount: 1,
48+
toString: () => 'test',
49+
getRangeAt: () => ({ getBoundingClientRect: () => ({}) }),
50+
};
51+
spyOn(document, 'getSelection').and.returnValue(selection as any);
52+
spyOn(directive, 'getRangeContainer').and.returnValue(elementRef.nativeElement);
53+
spyOn(elementRef.nativeElement, 'contains').and.returnValue(true);
54+
spyOn(directive, 'createTooltipComponent').and.returnValue({ instance: {}, hostView: {rootNodes: []} } as any);
55+
spyOn(document.body, 'appendChild').and.returnValue({} as Node);
56+
spyOn(appRef, 'attachView').and.returnValue();
57+
58+
directive.processSelection();
59+
60+
expect(directive.hasSelection).toBeTrue();
61+
expect(directive.selectedText).toBe('test');
62+
expect(directive.componentRef).toBeTruthy();
63+
});
64+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import {
2+
ApplicationRef,
3+
ComponentRef,
4+
createComponent,
5+
Directive,
6+
ElementRef,
7+
EmbeddedViewRef,
8+
EnvironmentInjector,
9+
Inject,
10+
Input,
11+
NgZone,
12+
OnDestroy,
13+
OnInit
14+
} from '@angular/core';
15+
import { TextSelectionTooltipComponent } from './text-selection-tooltip/text-selection-tooltip.component';
16+
import { DOCUMENT } from '@angular/common';
17+
18+
@Directive({
19+
selector: '[dsTextSelectTooltip]',
20+
})
21+
export class TextSelectDirective implements OnInit, OnDestroy {
22+
23+
@Input()
24+
showTTSControls = true;
25+
26+
hasSelection = false;
27+
selectedText = '';
28+
29+
componentRef: ComponentRef<any> = null;
30+
31+
// Initialize the directive.
32+
constructor(
33+
@Inject(DOCUMENT) private _document: Document,
34+
private elementRef: ElementRef,
35+
private zone: NgZone,
36+
private appRef: ApplicationRef,
37+
private injector: EnvironmentInjector
38+
) {
39+
}
40+
41+
// Clean up when the directive is destroyed.
42+
ngOnDestroy(): void {
43+
this.elementRef.nativeElement.removeEventListener('mousedown', this.handleMousedown, false);
44+
this._document.removeEventListener('mouseup', this.handleMouseup, false);
45+
}
46+
47+
// Set up event listeners when the directive is initialized.
48+
ngOnInit(): void {
49+
this.zone.runOutsideAngular(() => {
50+
this.elementRef.nativeElement.addEventListener('mousedown', this.handleMousedown, false);
51+
});
52+
}
53+
54+
// Get the deepest Element node in the DOM tree that contains the entire range.
55+
getRangeContainer(range: Range): Node {
56+
let container = range.commonAncestorContainer;
57+
while (container.nodeType !== Node.ELEMENT_NODE) {
58+
container = container.parentNode;
59+
}
60+
return (container);
61+
}
62+
63+
// Handle mousedown events inside the current element.
64+
handleMousedown = (): void => {
65+
this._document.addEventListener('mouseup', this.handleMouseup, false);
66+
};
67+
68+
// Handle mouseup events anywhere in the document.
69+
private handleMouseup = (): void => {
70+
this._document.removeEventListener('mouseup', this.handleMouseup, false);
71+
this.processSelection();
72+
};
73+
74+
createTooltipComponent(): ComponentRef<TextSelectionTooltipComponent> {
75+
return createComponent(TextSelectionTooltipComponent, {environmentInjector: this.injector});
76+
}
77+
78+
processSelection(): void {
79+
const selection = this._document.getSelection();
80+
const stringSelection = selection.toString().trim();
81+
const previousSelection = this.selectedText;
82+
83+
if (this.hasSelection) {
84+
this.zone.runGuarded(() => {
85+
this.hasSelection = false;
86+
this.selectedText = '';
87+
this.componentRef.destroy();
88+
this.componentRef = null;
89+
});
90+
}
91+
92+
// check if there is a selection and if it is different from the previous one
93+
// (to handle a bug in browsers that fires the mouseup event again if clicking on the selection)
94+
if (!selection.rangeCount || !stringSelection || previousSelection === stringSelection) {
95+
return;
96+
}
97+
console.warn('selection', stringSelection);
98+
let range = selection.getRangeAt(0);
99+
let rangeContainer = this.getRangeContainer(range);
100+
// check if the range container is inside the current element
101+
// (to avoid showing the tooltip when selecting text in other elements)
102+
if (this.elementRef.nativeElement.contains(rangeContainer)) {
103+
let viewportRectangle = range.getBoundingClientRect();
104+
if (stringSelection) {
105+
this.zone.runGuarded(() => {
106+
this.hasSelection = true;
107+
if (this.componentRef === null) {
108+
this.componentRef = this.createTooltipComponent();
109+
this.appRef.attachView(this.componentRef.hostView);
110+
111+
const domElem = (this.componentRef.hostView as EmbeddedViewRef<any>).rootNodes[0] as HTMLElement;
112+
this._document.body.appendChild(domElem);
113+
114+
this.componentRef.instance.elementRectangleLeft = viewportRectangle.left + window.scrollX;
115+
this.componentRef.instance.elementRectangleTop = viewportRectangle.top + window.scrollY;
116+
this.componentRef.instance.elementRectangleWidth = viewportRectangle.width;
117+
this.componentRef.instance.elementRectangleHeight = viewportRectangle.height;
118+
this.componentRef.instance.text = stringSelection;
119+
120+
this.componentRef.instance.showTTSControls = this.showTTSControls;
121+
122+
this.selectedText = stringSelection;
123+
}
124+
});
125+
}
126+
}
127+
}
128+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<div class="p-2 text-white">
2+
<div class="tts-controls" *ngIf="showTTSControls">
3+
<i *ngIf="!this.utterances.length" class="button fa fa-volume-high" (click)="textToSpeech()"></i>
4+
<i *ngIf="this.utterances.length && !isPaused" class="button fa fa-pause" (click)="pauseTextToSpeech()"></i>
5+
<i *ngIf="this.utterances.length && isPaused" class="button fa fa-play" (click)="resumeTextToSpeech()"></i>
6+
</div>
7+
</div>
8+
<div class="tooltip-arrow">&nbsp;</div>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
:host {
2+
position: absolute;
3+
z-index: 9999;
4+
background-color: var(--bs-gray-900);
5+
transform: translate(-50%, -100%);
6+
7+
&.bottom-placement {
8+
transform: translate(-50%, 0);
9+
div.tooltip-arrow {
10+
transform: translateX(-50%) translateY(-50%) rotate(45deg);
11+
bottom: initial;
12+
top: 0;
13+
}
14+
}
15+
}
16+
17+
i.button {
18+
cursor: pointer;
19+
}
20+
21+
div.tooltip-arrow {
22+
position: absolute;
23+
bottom: 0;
24+
left: 50%;
25+
background-color: var(--bs-gray-900);
26+
transform: translateX(-50%) translateY(50%) rotate(45deg);
27+
width: 12px;
28+
height: 12px;
29+
}
30+
31+
div.tts-controls {
32+
width: 20px;
33+
display: flex;
34+
justify-content: center;
35+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
import { TextSelectionTooltipComponent } from './text-selection-tooltip.component';
3+
import { ChangeDetectorRef, NgZone } from '@angular/core';
4+
import { TranslateService } from '@ngx-translate/core';
5+
import { NativeWindowService } from '../../../core/services/window.service';
6+
7+
describe('TextSelectionTooltipComponent', () => {
8+
let component: TextSelectionTooltipComponent;
9+
let fixture: ComponentFixture<TextSelectionTooltipComponent>;
10+
let changeDetectorRef: ChangeDetectorRef;
11+
let ngZone: NgZone;
12+
let translateService: TranslateService;
13+
14+
beforeEach(() => {
15+
TestBed.configureTestingModule({
16+
imports: [ TextSelectionTooltipComponent ],
17+
providers: [
18+
{ provide: ChangeDetectorRef, useValue: {detectChanges: () => fixture.detectChanges()} },
19+
{ provide: NgZone, useValue: new NgZone({}) },
20+
{ provide: TranslateService, useValue: { currentLang: 'en' } },
21+
{ provide: NativeWindowService, useValue: { nativeWindow: window } },
22+
]
23+
});
24+
25+
fixture = TestBed.createComponent(TextSelectionTooltipComponent);
26+
component = fixture.componentInstance;
27+
changeDetectorRef = TestBed.inject(ChangeDetectorRef);
28+
ngZone = TestBed.inject(NgZone);
29+
translateService = TestBed.inject(TranslateService);
30+
});
31+
32+
it('should create', () => {
33+
expect(component).toBeTruthy();
34+
});
35+
36+
it('should set up event listener on ngOnInit', () => {
37+
spyOn(window, 'addEventListener');
38+
component.ngOnInit();
39+
expect(window.addEventListener).toHaveBeenCalledWith('scroll', component.boundCheckPosition);
40+
});
41+
42+
it('should remove event listener on ngOnDestroy', () => {
43+
spyOn(window, 'removeEventListener');
44+
component.ngOnDestroy();
45+
expect(window.removeEventListener).toHaveBeenCalledWith('scroll', component.boundCheckPosition);
46+
});
47+
48+
it('should check position correctly', () => {
49+
spyOn(changeDetectorRef, 'detectChanges');
50+
component.elementRectangleTop = 100;
51+
component.elementRectangleHeight = 50;
52+
spyOnProperty(window, 'scrollY').and.returnValue(200);
53+
component.checkPosition();
54+
expect(component.top).toBe(156);
55+
expect(component.bottomPlacement).toBeTrue();
56+
});
57+
58+
it('should handle text to speech correctly', () => {
59+
spyOn(window.speechSynthesis, 'speak');
60+
component.text = 'test';
61+
component.textToSpeech();
62+
expect(window.speechSynthesis.speak).toHaveBeenCalled();
63+
});
64+
65+
it('should handle pause text to speech correctly', () => {
66+
spyOn(window.speechSynthesis, 'pause');
67+
component.pauseTextToSpeech();
68+
expect(window.speechSynthesis.pause).toHaveBeenCalled();
69+
expect(component.isPaused).toBeTrue();
70+
});
71+
72+
it('should handle resume text to speech correctly', () => {
73+
spyOn(window.speechSynthesis, 'resume');
74+
component.resumeTextToSpeech();
75+
expect(window.speechSynthesis.resume).toHaveBeenCalled();
76+
expect(component.isPaused).toBeFalse();
77+
});
78+
});

0 commit comments

Comments
 (0)