Capture selected text and its coordinates to show a contextual popup — like Medium's "Tweet this" feature.
Covers the Web Selection API, Angular directives, services, component design patterns, UML, and complete implementation.
Table of Contents
- Problem Statement
- How Medium's Text Selection Works
- Browser APIs Used
- Design Patterns Used
- Data Structures Used
- UML Diagrams
- Step-by-Step Process
- Code Structure
- Implementation — Service
- Implementation — Directive
- Implementation — Popup Component
- Implementation — Article Component (Consumer)
- Angular Standalone Component Version
- Edge Cases & Validation
- Testing the Implementation
- TypeScript Interfaces
- Key Concepts Tested
- Quick Reference
Problem Statement
Build an Angular feature that:
1. Listens for user text selection on a page (like Medium.com)
2. Detects which text was selected (highlighted)
3. Gets the pixel coordinates of the selection
4. Shows a popup/tooltip above the selection
5. Allows actions like: "Tweet this", "Share", "Copy", "Highlight"
6. Restricts selection to a specific element (e.g., article body only)
7. Hides popup when selection is cleared
Visual:
User selects text: "Angular is awesome"
↕
┌─────────────────────┐
│ 🐦 Tweet 📋 Copy │ ← popup appears above selection
└─────────────────────┘
"Angular is awesome for building..."
How Medium's Text Selection Works
1. Listen to mouseup event on document or specific container
2. On mouseup:
a. Get window.getSelection()
b. Check if selection is non-empty (length > 0)
c. Get the Range object (getBoundingClientRect())
d. Calculate x, y coordinates
e. Show popup at those coordinates
3. On mousedown (or clicking outside):
a. Hide popup
Browser APIs Used
window.getSelection()
// Returns a Selection object representing the text selected by the user
const selection = window.getSelection();
selection.toString() // The selected text as a string
selection.rangeCount // Number of ranges in the selection
selection.getRangeAt(0) // Get the first Range object
selection.anchorNode // The Node where selection starts
selection.focusNode // The Node where selection ends
selection.isCollapsed // true if selection has zero length
selection.removeAllRanges() // Clear the selection
// Range object
const range = selection.getRangeAt(0);
range.startContainer // Node where selection starts
range.endContainer // Node where selection ends
range.startOffset // Offset within startContainer
range.endOffset // Offset within endContainer
range.getBoundingClientRect() // DOMRect with x, y, width, height, top, bottom
// DOMRect from getBoundingClientRect():
// {
// x: 300, ← left edge of selection (relative to viewport)
// y: 450, ← top edge of selection
// width: 240, ← width of selection highlight
// height: 20, ← height (font size)
// top: 450,
// left: 300,
// right: 540,
// bottom: 470
// }
Containment Check
// Check if selection is within our target element
element.contains(range.startContainer) // returns boolean
element.contains(range.endContainer) // returns boolean
// Both must be true to confirm selection is inside our element
Design Patterns Used
1. Observer Pattern
The feature uses the Observer pattern at its core:
- mouseup event = event source (Observable)
- SelectionService = observer (processes and emits changes)
- TextHighlightDirective = subscribes to DOM events
- PopupComponent = observes SelectionService BehaviorSubject
Angular's EventEmitter and RxJS Subject/BehaviorSubject
implement the Observer pattern natively.
2. Directive Pattern (Angular-specific)
[appTextSelection] is an Attribute Directive:
- Attached to any element (<article>, <div>, etc.)
- Adds mouseup listener to that element
- Restricts selection detection to inside that element
- Emits selection changes to parent via EventEmitter
This decouples the selection logic from any specific component:
<article [appTextSelection] (selectionChange)="onSelect($event)">
...content...
</article>
3. Service + BehaviorSubject (Shared State)
SelectionService holds the current selection state:
selectionState$: BehaviorSubject<SelectionState | null>
Multiple components can subscribe:
- PopupComponent: watches for state to show/hide
- AnalyticsService: watches to log selected text
- HighlightService: watches to save highlights
This is the Pub-Sub pattern via RxJS.
4. Strategy Pattern (Coordinate Calculation)
Two strategies for positioning the popup:
Strategy A: Fixed position (relative to viewport)
x = rect.left + rect.width / 2 + scrollX
y = rect.top + window.scrollY
→ Works for position: fixed popup
Strategy B: Absolute position (relative to container)
x = rect.left - containerRect.left + rect.width / 2
y = rect.top - containerRect.top
→ Works for position: absolute popup inside container
The strategy is selected based on popup's CSS positioning.
Data Structures Used
// Core data model representing a text selection
interface SelectionState {
text: string; // The selected text content
x: number; // Horizontal center of selection (px)
y: number; // Top edge of selection (px) for popup placement
width: number; // Width of selection highlight
height: number; // Height of selection highlight
range: Range; // The browser Range object (for re-applying later)
startNode: Node; // Selection start node
endNode: Node; // Selection end node
}
// Popup positioning
interface PopupPosition {
left: number; // popup left position
top: number; // popup top position (above selection)
}
// State machine for the popup
type PopupState = 'hidden' | 'visible' | 'animating';
UML Diagrams
Component Architecture
AppComponent
└── ArticleComponent
├── [appTextSelection] Directive ←── AttributeDirective
│ ├── @HostListener('mouseup')
│ ├── window.getSelection()
│ ├── containment check (element.contains())
│ └── @Output selectionChange: EventEmitter<SelectionState>
│
├── SelectionService ←── Singleton Service
│ ├── selectionState$: BehaviorSubject<SelectionState | null>
│ ├── setSelection(state)
│ └── clearSelection()
│
└── SelectionPopupComponent ←── Smart Component
├── @Input selection: SelectionState
├── calculates popup position
├── renders action buttons
└── onTweetClick() / onCopyClick()
Sequence Diagram
User ArticleEl Directive SelectionService PopupComponent
│ │ │ │ │
│ mousedown │ │ │ │
│──────────────►─│ │ │ │
│ highlight text│ │ │
│ mouseup │ │ │ │
│──────────────►─│ │ │ │
│ │──mouseup──►│ │ │
│ │ │ getSelection() │ │
│ │ │ containsCheck()│ │
│ │ │ getBoundingClientRect() │
│ │ │─setSelection(state)─────────────►│
│ │ │ │ │
│ │ │ │ calcPopupPosition│
│ │ │ │ show popup ────►│
│ │ │ │ │ renders
│◄────────────────────────────────────────────────────────────────│
│ popup appears above selection │
│ │ │ │ │
│ clicks Tweet │ │ │ │
│───────────────────────────────────────────────────────────────►│
│ │ │ │ │ openTwitter
│ clicks outside │ │ │ │
│──────────────►─│ │ │ │
│ │──mousedown─► │ │
│ │ │─clearSelection()──────────────► │
│ │ │ │ popup hides ────►│
State Machine
text selected text cleared
┌─────────────────────┐ ┌────────────────┐
│ SELECTION EXISTS │◄─── mouseup │ NO SELECT │
│ show popup │ │ hide popup │
└─────────┬───────────┘ └────────────────┘
│ ▲
│ click Tweet │
▼ │ mousedown or
┌─────────────────────┐ click outside
│ TWEET INTENT │ │
│ open twitter.com ├──────────────┘
│ hide popup │
└─────────────────────┘
Step-by-Step Process
Step 1: Create SelectionService
- BehaviorSubject<SelectionState | null>
- setSelection(state) and clearSelection() methods
- Observable exposed to consumers
Step 2: Create [appTextSelection] Directive
- Takes optional @Input elementRef restriction
- @HostListener('mouseup') → triggers selection check
- Listen to document mousedown → clear selection
- Call window.getSelection()
- Validate: text not empty, nodes inside element
- Get Range.getBoundingClientRect()
- Calculate coordinates
- Emit via @Output OR update SelectionService
Step 3: Create SelectionPopupComponent
- Subscribe to SelectionService.selectionState$
- When state exists: calculate popup position, show
- When null: hide
- Render action buttons (Tweet, Copy, Highlight)
- Handle button clicks
Step 4: Integrate in ArticleComponent
- Apply directive to article container
- Include popup component in template
- Wire up tweet/share actions
Step 5: Style the popup
- position: fixed (relative to viewport)
- Transform to center above selection
- Arrow/caret pointing down
- Smooth show/hide animation
Code Structure
src/app/
├── services/
│ └── selection.service.ts ← BehaviorSubject + state management
├── directives/
│ └── text-selection.directive.ts ← mouseup listener + Selection API
├── components/
│ ├── selection-popup/
│ │ ├── selection-popup.component.ts
│ │ ├── selection-popup.component.html
│ │ └── selection-popup.component.scss
│ └── article/
│ ├── article.component.ts
│ ├── article.component.html
│ └── article.component.scss
└── models/
└── selection.model.ts ← interfaces and types
Implementation — Service
// selection.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
export interface SelectionCoordinates {
x: number; // horizontal center of selection
y: number; // top of selection (for popup above)
width: number; // selection width
height: number; // selection height
left: number; // left edge
right: number; // right edge
bottom: number; // bottom edge
top: number; // top edge
}
export interface SelectionState {
text: string; // the selected text
coordinates: SelectionCoordinates; // bounding rect + center
range: Range; // Range object for re-selection
}
@Injectable({ providedIn: 'root' })
export class SelectionService {
// BehaviorSubject holds current state:
// null = no active selection
// state = active selection with text + coordinates
private selectionSubject = new BehaviorSubject<SelectionState | null>(null);
// Expose as Observable (read-only to consumers)
public selectionState$: Observable<SelectionState | null> =
this.selectionSubject.asObservable();
/**
* Set the current selection state.
* Called by the directive when a valid selection is detected.
*/
setSelection(state: SelectionState): void {
this.selectionSubject.next(state);
}
/**
* Clear the current selection state.
* Called when user clicks outside, clears selection, or presses Escape.
*/
clearSelection(): void {
this.selectionSubject.next(null);
}
/**
* Get current selection synchronously (for one-time reads).
*/
get currentSelection(): SelectionState | null {
return this.selectionSubject.getValue();
}
}
Implementation — Directive
// text-selection.directive.ts
import {
Directive,
ElementRef,
EventEmitter,
HostListener,
Input,
OnDestroy,
Output
} from '@angular/core';
import { SelectionService, SelectionState, SelectionCoordinates } from '../services/selection.service';
@Directive({
selector: '[appTextSelection]',
standalone: true,
})
export class TextSelectionDirective implements OnDestroy {
/**
* If true, restrict text selection detection to ONLY within this element.
* Default: true (recommended — prevents cross-element selection issues).
*/
@Input() restrictToElement: boolean = true;
/**
* Emits when a valid selection is detected.
* Allows parent component to react without subscribing to service.
*/
@Output() selectionChange = new EventEmitter<SelectionState | null>();
/**
* Minimum characters required to show popup.
* Prevents popup on single-character accidental selections.
*/
@Input() minSelectionLength: number = 3;
private mousedownListener!: () => void;
constructor(
private elementRef: ElementRef<HTMLElement>,
private selectionService: SelectionService
) {
// Listen for mousedown on document to clear selection when clicking outside
this.mousedownListener = this.onDocumentMousedown.bind(this);
document.addEventListener('mousedown', this.mousedownListener);
}
ngOnDestroy(): void {
document.removeEventListener('mousedown', this.mousedownListener);
}
// ── mouseup: Check if text was selected ──────────────────────────────────
@HostListener('mouseup', ['$event'])
onMouseUp(event: MouseEvent): void {
// Small delay: let browser finalise selection before we read it
setTimeout(() => this.processSelection(), 0);
}
// ── Also handle keyboard selection (Shift+Arrow keys) ─────────────────
@HostListener('keyup', ['$event'])
onKeyUp(event: KeyboardEvent): void {
if (event.shiftKey) {
setTimeout(() => this.processSelection(), 0);
}
}
// ── Core selection processing ─────────────────────────────────────────
private processSelection(): void {
const selection = window.getSelection();
// Guard 1: No selection object
if (!selection) {
this.clearSelection();
return;
}
// Guard 2: Selection is collapsed (cursor only, no text highlighted)
if (selection.isCollapsed) {
this.clearSelection();
return;
}
// Guard 3: No selected text
const text = selection.toString().trim();
if (!text || text.length < this.minSelectionLength) {
this.clearSelection();
return;
}
// Guard 4: No valid range
if (selection.rangeCount === 0) {
this.clearSelection();
return;
}
const range = selection.getRangeAt(0);
// Guard 5: Restrict to element (if enabled)
if (this.restrictToElement) {
const container = this.elementRef.nativeElement;
// Check if BOTH start and end nodes are inside our element
const startInside = container.contains(range.startContainer);
const endInside = container.contains(range.endContainer);
if (!startInside || !endInside) {
this.clearSelection();
return;
}
}
// ── All guards passed: get coordinates and emit state ─────────────────
const rect = range.getBoundingClientRect();
const scrollX = window.scrollX || window.pageXOffset;
const scrollY = window.scrollY || window.pageYOffset;
const coordinates: SelectionCoordinates = {
// Center X of selection + scroll offset (for position: absolute)
x: rect.left + rect.width / 2 + scrollX,
// Top Y of selection + scroll offset (popup will be shown above this)
y: rect.top + scrollY,
width: rect.width,
height: rect.height,
left: rect.left + scrollX,
right: rect.right + scrollX,
top: rect.top + scrollY,
bottom: rect.bottom + scrollY,
};
const state: SelectionState = {
text,
coordinates,
range,
};
// Update service (shared state — popup subscribes to this)
this.selectionService.setSelection(state);
// Also emit directly (for parent component local handling)
this.selectionChange.emit(state);
}
// ── Clear on document mousedown (click outside) ──────────────────────────
private onDocumentMousedown(event: MouseEvent): void {
// Don't clear if click is ON the popup (handled by popup component)
const target = event.target as HTMLElement;
// Check if click is inside the popup (by class or data attribute)
if (target.closest('[data-selection-popup]')) {
return; // user clicked inside popup — don't clear
}
this.clearSelection();
}
private clearSelection(): void {
this.selectionService.clearSelection();
this.selectionChange.emit(null);
}
}
Implementation — Popup Component
// selection-popup.component.ts
import {
Component,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
ChangeDetectorRef,
HostListener
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { SelectionService, SelectionState } from '../../services/selection.service';
interface PopupPosition {
left: number;
top: number;
transform: string;
}
@Component({
selector: 'app-selection-popup',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
*ngIf="isVisible && selection"
data-selection-popup
class="selection-popup"
[ngStyle]="popupStyle"
role="toolbar"
aria-label="Text selection actions"
>
<!-- Tweet button -->
<button
class="popup-btn popup-btn--tweet"
(click)="onTweet()"
title="Tweet selected text"
aria-label="Share on Twitter"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.734l7.735-8.87L1.254 2.25H8.08l4.258 5.63zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
Tweet
</button>
<!-- Copy button -->
<button
class="popup-btn popup-btn--copy"
(click)="onCopy()"
title="Copy selected text"
aria-label="Copy text"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
{{ copied ? 'Copied!' : 'Copy' }}
</button>
<!-- Highlight button -->
<button
class="popup-btn popup-btn--highlight"
(click)="onHighlight()"
title="Highlight selected text"
aria-label="Highlight text"
>
✏️ Highlight
</button>
<!-- Caret arrow pointing down -->
<div class="popup-caret" aria-hidden="true"></div>
</div>
`,
styles: [`
.selection-popup {
position: fixed; /* or absolute — depends on coordinate type */
background: #1a1a2e;
color: #ffffff;
border-radius: 8px;
padding: 6px 8px;
display: flex;
align-items: center;
gap: 4px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
z-index: 9999;
pointer-events: auto;
user-select: none;
white-space: nowrap;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(4px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.popup-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
background: transparent;
color: #ffffff;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background 0.15s ease;
}
.popup-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.popup-btn--tweet { color: #1da1f2; }
.popup-btn--tweet:hover { background: rgba(29, 161, 242, 0.15); }
.popup-btn--copy { color: #a8d8a8; }
.popup-btn--highlight { color: #ffd700; }
/* Separator between buttons */
.popup-btn + .popup-btn {
border-left: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0 5px 5px 0;
}
/* Arrow/caret pointing down toward selection */
.popup-caret {
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid #1a1a2e;
}
`]
})
export class SelectionPopupComponent implements OnInit, OnDestroy {
selection: SelectionState | null = null;
isVisible: boolean = false;
copied: boolean = false;
popupStyle: Record<string, string> = {};
private destroy$ = new Subject<void>();
private copyTimeout?: ReturnType<typeof setTimeout>;
// Popup dimensions (approximate — used for offset calculation)
private readonly POPUP_HEIGHT = 44; // px
private readonly POPUP_OFFSET = 10; // gap between popup and selection
constructor(
private selectionService: SelectionService,
private cdr: ChangeDetectorRef
) {}
ngOnInit(): void {
this.selectionService.selectionState$.pipe(
takeUntil(this.destroy$)
).subscribe(state => {
this.selection = state;
this.isVisible = !!state;
if (state) {
this.popupStyle = this.calculatePopupStyle(state);
}
this.cdr.markForCheck(); // OnPush: manually trigger detection
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
if (this.copyTimeout) clearTimeout(this.copyTimeout);
}
// ── Coordinate Calculation ────────────────────────────────────────────────
private calculatePopupStyle(state: SelectionState): Record<string, string> {
const { coordinates } = state;
// Position popup centered above the selection
// x = horizontal center of selection
// y = top of selection - popup height - gap
const left = coordinates.x; // center
const top = coordinates.y - this.POPUP_HEIGHT - this.POPUP_OFFSET; // above
return {
left: `${left}px`,
top: `${top}px`,
transform: 'translateX(-50%)', // center the popup on x
};
}
// ── Actions ───────────────────────────────────────────────────────────────
onTweet(): void {
if (!this.selection) return;
const text = encodeURIComponent(`"${this.selection.text}"`);
const url = encodeURIComponent(window.location.href);
const twitterUrl = `https://twitter.com/intent/tweet?text=${text}&url=${url}`;
window.open(twitterUrl, '_blank', 'noopener,noreferrer,width=550,height=420');
this.selectionService.clearSelection();
}
onCopy(): void {
if (!this.selection) return;
navigator.clipboard.writeText(this.selection.text).then(() => {
this.copied = true;
this.cdr.markForCheck();
this.copyTimeout = setTimeout(() => {
this.copied = false;
this.selectionService.clearSelection();
this.cdr.markForCheck();
}, 1500);
});
}
onHighlight(): void {
if (!this.selection) return;
// Implement highlight logic (save to DB, mark in DOM, etc.)
console.log('Highlight:', this.selection.text);
// Optionally persist in localStorage or backend
this.selectionService.clearSelection();
}
// ── Keyboard support ──────────────────────────────────────────────────────
@HostListener('document:keydown.escape')
onEscape(): void {
this.selectionService.clearSelection();
}
}
Implementation — Article Component (Consumer)
// article.component.ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TextSelectionDirective } from '../../directives/text-selection.directive';
import { SelectionPopupComponent } from '../selection-popup/selection-popup.component';
import { SelectionState } from '../../services/selection.service';
@Component({
selector: 'app-article',
standalone: true,
imports: [CommonModule, TextSelectionDirective, SelectionPopupComponent],
template: `
<!-- The popup component listens to SelectionService globally -->
<app-selection-popup></app-selection-popup>
<!-- Directive applied to this container restricts selection detection here -->
<article
appTextSelection
[restrictToElement]="true"
[minSelectionLength]="5"
(selectionChange)="onSelectionChange($event)"
class="article-body"
>
<h1>Understanding Angular Signals</h1>
<p>
Angular has introduced <strong>Signals</strong> as a new reactive
primitive in version 16. Signals provide a way to manage state that
automatically notifies dependents when the value changes, enabling
fine-grained reactivity without Zone.js.
</p>
<p>
Unlike traditional change detection which relies on zone patches and
full component tree traversal, Signals allow Angular to know exactly
which components need to update and when.
</p>
<blockquote>
"Signals are the future of Angular reactivity — providing a clean,
efficient way to manage state in modern applications."
</blockquote>
<p>
Select any text in this article to see the Medium-style popup appear
above your selection. You can then tweet the quote, copy it, or
highlight it for later reference.
</p>
</article>
<!-- Optional: Show current selection info for debugging -->
<div *ngIf="currentSelection" class="selection-debug">
<strong>Selected:</strong> "{{ currentSelection.text }}"
<br>
<strong>Position:</strong> x={{ currentSelection.coordinates.x | number:'1.0-0' }},
y={{ currentSelection.coordinates.y | number:'1.0-0' }}
</div>
`,
styles: [`
.article-body {
max-width: 720px;
margin: 2rem auto;
padding: 2rem;
font-family: Georgia, serif;
font-size: 18px;
line-height: 1.8;
color: #1a1a1a;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 16px rgba(0,0,0,0.06);
}
h1 { font-size: 2rem; margin-bottom: 1.5rem; }
p { margin-bottom: 1.25rem; }
blockquote {
border-left: 4px solid #6366f1;
padding-left: 1.25rem;
margin: 1.5rem 0;
font-style: italic;
color: #475569;
}
strong { font-weight: 700; }
/* Highlight selected text */
::selection {
background: #c7d2fe;
color: #1e1b4b;
}
.selection-debug {
max-width: 720px;
margin: 1rem auto;
padding: 0.75rem 1rem;
background: #f1f5f9;
border-radius: 6px;
font-family: monospace;
font-size: 13px;
color: #475569;
}
`]
})
export class ArticleComponent {
currentSelection: SelectionState | null = null;
onSelectionChange(state: SelectionState | null): void {
this.currentSelection = state;
if (state) {
console.log('Text selected:', state.text);
console.log('Coordinates:', state.coordinates);
}
}
}
Angular Standalone Component Version
// app.component.ts — minimal self-contained example
import { Component, Directive, ElementRef, HostListener,
EventEmitter, Output, Input, OnInit, OnDestroy,
ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BehaviorSubject, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
// ── Inline types ──────────────────────────────────────────────────────────
interface SelectionCoordinates {
x: number; y: number; width: number; height: number;
left: number; right: number; top: number; bottom: number;
}
interface SelectionState {
text: string;
coordinates: SelectionCoordinates;
range: Range;
}
// ── Global selection state (singleton pattern without DI) ──────────────────
const selectionState$ = new BehaviorSubject<SelectionState | null>(null);
// ── Directive ─────────────────────────────────────────────────────────────
@Directive({ selector: '[appTextSelection]', standalone: true })
export class TextSelectionDirective implements OnDestroy {
@Input() restrictToElement: boolean = true;
@Input() minSelectionLength: number = 3;
@Output() selectionChange = new EventEmitter<SelectionState | null>();
private mousedownFn = () => this.clearSelection();
constructor(private el: ElementRef<HTMLElement>) {
document.addEventListener('mousedown', this.mousedownFn);
}
ngOnDestroy() { document.removeEventListener('mousedown', this.mousedownFn); }
@HostListener('mouseup') onMouseUp() {
setTimeout(() => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) { this.clearSelection(); return; }
const text = sel.toString().trim();
if (text.length < this.minSelectionLength) { this.clearSelection(); return; }
const range = sel.getRangeAt(0);
const container = this.el.nativeElement;
if (this.restrictToElement &&
(!container.contains(range.startContainer) ||
!container.contains(range.endContainer))) {
this.clearSelection();
return;
}
const r = range.getBoundingClientRect();
const sx = window.scrollX, sy = window.scrollY;
const state: SelectionState = {
text,
range,
coordinates: {
x: r.left + r.width / 2 + sx, y: r.top + sy,
width: r.width, height: r.height,
left: r.left + sx, right: r.right + sx,
top: r.top + sy, bottom: r.bottom + sy,
}
};
selectionState$.next(state);
this.selectionChange.emit(state);
}, 0);
}
private clearSelection() {
selectionState$.next(null);
this.selectionChange.emit(null);
}
}
Edge Cases & Validation
// ── Edge Case 1: Multi-line selection ─────────────────────────────────────
// When selection spans multiple lines, getBoundingClientRect() returns
// the rectangle of the FIRST line only (not the full selection).
// For popup placement this is fine — show popup above first line.
// ── Edge Case 2: Selection in different directions ────────────────────────
// User can select right-to-left or left-to-right.
// selection.anchorNode = where mouse went DOWN
// selection.focusNode = where mouse went UP
// range.startContainer is always the DOM-order start (not click-order)
// → getBoundingClientRect() always returns correct rect regardless of direction
// ── Edge Case 3: Selection across element boundaries ────────────────────
// <p>Hello <strong>World</strong> foo</p>
// If user selects "llo Wor":
// startContainer = TextNode inside <p> ("Hello ")
// endContainer = TextNode inside <strong> ("World")
// element.contains() correctly handles this for both nodes
// ── Edge Case 4: iframe content ──────────────────────────────────────────
// window.getSelection() only gets selection in the current window.
// For iframes: iframe.contentWindow.getSelection()
// → not needed for typical article pages
// ── Edge Case 5: Rapid selection changes ─────────────────────────────────
// User drags selection — mouseup fires once at end.
// setTimeout(0) ensures selection is finalised before we read it.
// ── Edge Case 6: Mobile (touch events) ───────────────────────────────────
// Touch devices use touchend instead of mouseup for selection.
// Add @HostListener('touchend') that calls the same processSelection().
// ── Edge Case 7: Popup position near viewport edges ───────────────────────
private clampPopupPosition(left: number, top: number): { left: number; top: number } {
const POPUP_WIDTH = 220;
const margin = 10;
const vw = window.innerWidth;
// Prevent popup from going off-screen horizontally
const clampedLeft = Math.max(
margin + POPUP_WIDTH / 2,
Math.min(left, vw - margin - POPUP_WIDTH / 2)
);
// Prevent popup from going above viewport
const clampedTop = Math.max(margin, top);
return { left: clampedLeft, top: clampedTop };
}
// ── Edge Case 8: SSR (Server-Side Rendering) ──────────────────────────────
// window and document are not available in SSR.
// Guard:
if (typeof window !== 'undefined') {
const selection = window.getSelection();
}
Testing the Implementation
// text-selection.directive.spec.ts
import { Component } from '@angular/core';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TextSelectionDirective } from './text-selection.directive';
import { SelectionService } from '../services/selection.service';
@Component({
template: `
<div appTextSelection (selectionChange)="onChange($event)">
<p id="para">Angular is awesome</p>
</div>
`
})
class TestHostComponent {
lastSelection: any = null;
onChange(state: any) { this.lastSelection = state; }
}
function mockSelection(text: string, startNode: Node, endNode: Node): void {
const mockRange = {
startContainer: startNode,
endContainer: endNode,
getBoundingClientRect: () => ({
left: 100, right: 300, top: 200, bottom: 220,
width: 200, height: 20, x: 100, y: 200
})
} as unknown as Range;
spyOn(window, 'getSelection').and.returnValue({
isCollapsed: false,
rangeCount: 1,
toString: () => text,
getRangeAt: () => mockRange,
} as unknown as Selection);
}
describe('TextSelectionDirective', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
let service: SelectionService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TextSelectionDirective],
declarations: [TestHostComponent],
providers: [SelectionService],
});
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
service = TestBed.inject(SelectionService);
fixture.detectChanges();
});
it('should emit selection state on mouseup with valid text', fakeAsync(() => {
const para = fixture.debugElement.query(By.css('#para')).nativeElement;
const node = para.firstChild as Text;
mockSelection('Angular is awesome', node, node);
const container = fixture.debugElement.query(By.css('div'));
container.triggerEventHandler('mouseup', {});
tick(10);
expect(host.lastSelection).toBeTruthy();
expect(host.lastSelection.text).toBe('Angular is awesome');
}));
it('should NOT emit when selection is outside element', fakeAsync(() => {
const outsideNode = document.createElement('p').firstChild || document.body.firstChild;
mockSelection('outside text', document.body, document.body as unknown as Node);
const container = fixture.debugElement.query(By.css('div'));
container.triggerEventHandler('mouseup', {});
tick(10);
expect(host.lastSelection).toBeNull();
}));
it('should clear selection on document mousedown', fakeAsync(() => {
const para = fixture.debugElement.query(By.css('#para')).nativeElement;
const node = para.firstChild as Text;
mockSelection('Angular is awesome', node, node);
const container = fixture.debugElement.query(By.css('div'));
container.triggerEventHandler('mouseup', {});
tick(10);
expect(service.currentSelection).toBeTruthy();
// Simulate click outside (not inside popup)
document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
tick();
expect(service.currentSelection).toBeNull();
}));
it('should NOT emit for selections shorter than minSelectionLength', fakeAsync(() => {
const para = fixture.debugElement.query(By.css('#para')).nativeElement;
const node = para.firstChild as Text;
mockSelection('Hi', node, node); // only 2 chars — below min of 3
const container = fixture.debugElement.query(By.css('div'));
container.triggerEventHandler('mouseup', {});
tick(10);
expect(host.lastSelection).toBeNull();
}));
});
TypeScript Interfaces
// models/selection.model.ts — all types in one place
export interface SelectionCoordinates {
/** Horizontal center of selection (viewport + scroll offset) */
x: number;
/** Top edge of selection (viewport + scroll offset) */
y: number;
width: number;
height: number;
left: number;
right: number;
top: number;
bottom: number;
}
export interface SelectionState {
/** The selected text string */
text: string;
/** Bounding box + center coordinates */
coordinates: SelectionCoordinates;
/** Raw browser Range object */
range: Range;
}
export interface PopupAction {
id: string;
label: string;
icon?: string;
handler: (selection: SelectionState) => void;
}
export type ValidationMode = 'strict' | 'loose';
// strict: both start AND end nodes must be inside element
// loose: at least one node must be inside element
export interface TextSelectionConfig {
minSelectionLength: number;
restrictToElement: boolean;
validationMode: ValidationMode;
popupOffset: number; // px gap between popup and selection
}
Key Concepts Tested
| Concept | Where it appears |
|---|---|
window.getSelection() |
Core browser API to get selected text |
selection.getRangeAt(0) |
Get Range object with start/end nodes |
range.getBoundingClientRect() |
Get pixel coordinates of selection |
element.contains(node) |
Check if a node is inside our target element |
selection.isCollapsed |
Detect cursor-only vs actual text selection |
@HostListener('mouseup') |
Listen to mouseup event on directive element |
BehaviorSubject |
Share selection state across components |
takeUntil |
Auto-unsubscribe on component destroy |
ChangeDetectionStrategy.OnPush |
Manual markForCheck() for performance |
| Attribute Directive |
[appTextSelection] decorates any element |
@Output EventEmitter |
Emit selection to parent component |
data-* attribute |
Mark popup to prevent self-clearing on click |
window.scrollX/scrollY |
Adjust coordinates for page scroll offset |
navigator.clipboard.writeText |
Async clipboard copy on "Copy" action |
Quick Reference
// ── Core implementation in ~30 lines ──────────────────────────────────────
@Directive({ selector: '[appTextSelection]', standalone: true })
export class TextSelectionDirective {
@Output() selectionChange = new EventEmitter<any>();
constructor(private el: ElementRef<HTMLElement>) {}
@HostListener('mouseup')
onMouseUp() {
setTimeout(() => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) { this.selectionChange.emit(null); return; }
const text = sel.toString().trim();
if (!text) { this.selectionChange.emit(null); return; }
const range = sel.getRangeAt(0);
const el = this.el.nativeElement;
// Restrict to element
if (!el.contains(range.startContainer) || !el.contains(range.endContainer)) {
this.selectionChange.emit(null);
return;
}
const r = range.getBoundingClientRect();
this.selectionChange.emit({
text,
x: r.left + r.width / 2 + window.scrollX, // center X
y: r.top + window.scrollY, // top Y
});
}, 0);
}
}
// ── Usage ─────────────────────────────────────────────────────────────────
// Template:
// <article appTextSelection (selectionChange)="onSelect($event)">
// ... content ...
// </article>
// <app-selection-popup></app-selection-popup> <!-- listens to SelectionService -->
// ── Twitter URL generation ────────────────────────────────────────────────
const tweetUrl = [
'https://twitter.com/intent/tweet?text=',
encodeURIComponent(`"${selectedText}"`),
'&url=',
encodeURIComponent(window.location.href),
].join('');
window.open(tweetUrl, '_blank', 'noopener,noreferrer,width=550,height=420');
// ── Key rules ─────────────────────────────────────────────────────────────
// 1. Use window.getSelection() AFTER mouseup (setTimeout(fn, 0) for safety)
// 2. Check selection.isCollapsed — true means no text selected
// 3. Use element.contains() to restrict to specific container
// 4. Use range.getBoundingClientRect() for popup coordinates
// 5. Add window.scrollX/Y for absolute positioning (not needed for fixed)
// 6. Guard popup click with data-attribute to prevent self-clearing
Top comments (0)