Skip to content

Commit 9595f41

Browse files
author
Andrea Barbasso
committed
[UXP-126] create text-select directive
1 parent 258f739 commit 9595f41

6 files changed

Lines changed: 191 additions & 5 deletions

File tree

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+
(dsTextSelect)="onTextSelect($event)"
23
[shouldShowFullscreenLoader]="(isAuthBlocking$ | async) || (isThemeLoading$ | async)"
34
[shouldShowRouteLoader]="isRouteLoading$ | async"></ds-themed-root>

src/app/app.component.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { distinctNext } from './core/shared/distinct-next';
2929
import { RouteService } from './core/services/route.service';
3030
import { getEditItemPageRoute, getWorkflowItemModuleRoute, getWorkspaceItemModuleRoute } from './app-routing-paths';
3131
import { SocialService } from './social/social.service';
32+
import { TextSelectEvent } from './directives/text-select/text-select.directive';
3233

3334
@Component({
3435
selector: 'ds-app',
@@ -166,4 +167,11 @@ export class AppComponent implements OnInit, AfterViewInit {
166167
});
167168
}
168169

170+
onTextSelect(event: TextSelectEvent) {
171+
console.group('Text selected');
172+
console.warn('Text selected:', event.text);
173+
console.warn('Viewport rectangle:', event.viewportRectangle);
174+
console.warn('Host rectangle:', event.hostRectangle);
175+
console.groupEnd();
176+
}
169177
}

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: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { TextSelectDirective } from './text-select.directive';
2+
import { ElementRef, NgZone } from '@angular/core';
3+
import { TestBed } from '@angular/core/testing';
4+
5+
describe('TextSelectDirective', () => {
6+
let directive: TextSelectDirective;
7+
let elementRef: ElementRef;
8+
let ngZone: NgZone;
9+
10+
beforeEach(() => {
11+
TestBed.configureTestingModule({
12+
providers: [
13+
TextSelectDirective,
14+
{ provide: ElementRef, useValue: { nativeElement: document.createElement('div') } },
15+
NgZone
16+
]
17+
});
18+
19+
directive = TestBed.inject(TextSelectDirective);
20+
elementRef = TestBed.inject(ElementRef);
21+
ngZone = TestBed.inject(NgZone);
22+
});
23+
24+
it('should create an instance', () => {
25+
expect(directive).toBeTruthy();
26+
});
27+
});
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { Directive, ElementRef, EventEmitter, NgZone, OnDestroy, OnInit, Output } from '@angular/core';
2+
3+
// Define the structure of the event that will be emitted when text is selected.
4+
export interface TextSelectEvent {
5+
text: string;
6+
viewportRectangle: SelectionRectangle | null;
7+
hostRectangle: SelectionRectangle | null;
8+
}
9+
10+
// Define the structure of the selection rectangle.
11+
interface SelectionRectangle {
12+
left: number;
13+
top: number;
14+
width: number;
15+
height: number;
16+
}
17+
18+
// This directive emits an event when the user selects text within the host element.
19+
@Directive({
20+
selector: '[dsTextSelect]',
21+
})
22+
export class TextSelectDirective implements OnInit, OnDestroy {
23+
24+
// Event emitter for the text select event.
25+
@Output()
26+
dsTextSelect: EventEmitter<TextSelectEvent> = new EventEmitter();
27+
28+
hasSelection = false;
29+
30+
// Initialize the directive.
31+
constructor(
32+
private elementRef: ElementRef,
33+
private zone: NgZone
34+
) {
35+
}
36+
37+
// Clean up when the directive is destroyed.
38+
public ngOnDestroy(): void {
39+
this.elementRef.nativeElement.removeEventListener('mousedown', this.handleMousedown, false);
40+
document.removeEventListener('mouseup', this.handleMouseup, false);
41+
document.removeEventListener('selectionchange', this.handleSelectionchange, false);
42+
}
43+
44+
// Set up event listeners when the directive is initialized.
45+
public ngOnInit(): void {
46+
this.zone.runOutsideAngular(() => {
47+
this.elementRef.nativeElement.addEventListener('mousedown', this.handleMousedown, false);
48+
document.addEventListener('selectionchange', this.handleSelectionchange, false);
49+
});
50+
}
51+
52+
// Get the deepest Element node in the DOM tree that contains the entire range.
53+
private getRangeContainer(range: Range): Node {
54+
let container = range.commonAncestorContainer;
55+
while (container.nodeType !== Node.ELEMENT_NODE) {
56+
container = container.parentNode;
57+
}
58+
return (container);
59+
}
60+
61+
// Handle mousedown events inside the current element.
62+
private handleMousedown = (): void => {
63+
document.addEventListener('mouseup', this.handleMouseup, false);
64+
};
65+
66+
// Handle mouseup events anywhere in the document.
67+
private handleMouseup = (): void => {
68+
document.removeEventListener('mouseup', this.handleMouseup, false);
69+
this.processSelection();
70+
};
71+
72+
// Handle selectionchange events anywhere in the document.
73+
private handleSelectionchange = (): void => {
74+
if (this.hasSelection) {
75+
this.processSelection();
76+
}
77+
};
78+
79+
// Inspect the document's current selection and check to see if it should be
80+
// emitted as a TextSelectEvent within the current element.
81+
private processSelection(): void {
82+
let selection = document.getSelection();
83+
if (this.hasSelection) {
84+
this.zone.runGuarded(() => {
85+
this.hasSelection = false;
86+
this.dsTextSelect.next({
87+
text: '',
88+
viewportRectangle: null,
89+
hostRectangle: null
90+
});
91+
});
92+
}
93+
if (!selection.rangeCount || !selection.toString()) {
94+
return;
95+
}
96+
let range = selection.getRangeAt(0);
97+
let rangeContainer = this.getRangeContainer(range);
98+
if (this.elementRef.nativeElement.contains(rangeContainer)) {
99+
let viewportRectangle = range.getBoundingClientRect();
100+
let localRectangle = this.viewportToHost(viewportRectangle, rangeContainer);
101+
const stringSelection = selection.toString();
102+
if (stringSelection) {
103+
this.zone.runGuarded(() => {
104+
this.hasSelection = true;
105+
this.dsTextSelect.emit({
106+
text: stringSelection,
107+
viewportRectangle: {
108+
left: viewportRectangle.left,
109+
top: viewportRectangle.top,
110+
width: viewportRectangle.width,
111+
height: viewportRectangle.height
112+
},
113+
hostRectangle: {
114+
left: localRectangle.left,
115+
top: localRectangle.top,
116+
width: localRectangle.width,
117+
height: localRectangle.height
118+
}
119+
});
120+
});
121+
}
122+
}
123+
}
124+
125+
// Convert the given viewport-relative rectangle to a host-relative rectangle.
126+
private viewportToHost(
127+
viewportRectangle: SelectionRectangle,
128+
rangeContainer: Node
129+
): SelectionRectangle {
130+
let host = this.elementRef.nativeElement;
131+
let hostRectangle = host.getBoundingClientRect();
132+
let localLeft = (viewportRectangle.left - hostRectangle.left);
133+
let localTop = (viewportRectangle.top - hostRectangle.top);
134+
let node = rangeContainer;
135+
do {
136+
localLeft += (<Element>node).scrollLeft;
137+
localTop += (<Element>node).scrollTop;
138+
} while ((node !== host) && (node = node.parentNode));
139+
return ({
140+
left: localLeft,
141+
top: localTop,
142+
width: viewportRectangle.width,
143+
height: viewportRectangle.height
144+
});
145+
}
146+
}

0 commit comments

Comments
 (0)