DEV Community

Mahendranath Reddy
Mahendranath Reddy

Posted on

Text Selection with Popup (Medium-style) — Angular

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

  1. Problem Statement
  2. How Medium's Text Selection Works
  3. Browser APIs Used
  4. Design Patterns Used
  5. Data Structures Used
  6. UML Diagrams
  7. Step-by-Step Process
  8. Code Structure
  9. Implementation — Service
  10. Implementation — Directive
  11. Implementation — Popup Component
  12. Implementation — Article Component (Consumer)
  13. Angular Standalone Component Version
  14. Edge Cases & Validation
  15. Testing the Implementation
  16. TypeScript Interfaces
  17. Key Concepts Tested
  18. 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..."
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
// }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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 ────►│
Enter fullscreen mode Exit fullscreen mode

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         │
   └─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
  }));
});
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)