Работа с текстовыми выделениями

Работа с текстовыми выделениями в интерфейсе требует высокой точности позиционирования. В отличие от стандартных элементов (кнопок, блоков), выделение текста может:

  • занимать произвольную форму
  • разбиваться на несколько строк
  • изменяться динамически при прокрутке или изменении размера окна

Библиотека Floating UI предоставляет инструменты для корректного вычисления позиции всплывающих элементов (tooltip, popover, contextual menu), привязанных к выделенному тексту.

Ключевым объектом в этой задаче является Range и его геометрическое представление через getBoundingClientRect() или getClientRects().


Получение геометрии выделенного текста

Выделение в браузере представлено через API Selection:

const selection = window.getSelection();

if (!selection.rangeCount) return;

const range = selection.getRangeAt(0);

Для позиционирования используются два подхода:

1. Общая рамка выделения:

const rect = range.getBoundingClientRect();

Подходит для простых случаев, когда выделение занимает одну строку.

2. Набор прямоугольников:

const rects = Array.from(range.getClientRects());

Используется, если текст переносится на несколько строк. В этом случае возможно:

  • выбрать первый прямоугольник
  • выбрать последний
  • вычислить объединённую область вручную

Виртуальные элементы в Floating UI

Floating UI не требует наличия реального DOM-элемента. Для работы с выделением создаётся виртуальный элемент:

const virtualElement = {
  getBoundingClientRect() {
    return range.getBoundingClientRect();
  }
};

Этот объект полностью совместим с API библиотеки.


Базовое позиционирование

import { computePosition } from '@floating-ui/dom';

computePosition(virtualElement, floatingElement).then(({ x, y }) => {
  Object.assign(floatingElement.style, {
    left: `${x}px`,
    top: `${y}px`
  });
});

Здесь:

  • virtualElement — выделенный текст
  • floatingElement — tooltip или меню

Использование middleware

Для точной настройки применяются middleware:

Смещение (offset)

import { offset } from '@floating-ui/dom';

middleware: [offset(8)]

Добавляет расстояние между выделением и всплывающим элементом.


Переворот (flip)

import { flip } from '@floating-ui/dom';

middleware: [flip()]

Перемещает элемент, если он выходит за границы экрана.


Сдвиг (shift)

import { shift } from '@floating-ui/dom';

middleware: [shift()]

Корректирует позицию, чтобы элемент оставался в пределах viewport.


Стрелка (arrow)

Если используется стрелка:

import { arrow } from '@floating-ui/dom';

middleware: [
  arrow({ element: arrowElement })
]

Требует дополнительной настройки координат стрелки.


Работа с многострочным выделением

Если выделение занимает несколько строк, стандартный getBoundingClientRect() может давать неудобный результат (слишком большой прямоугольник).

Более точный подход — выбор последнего прямоугольника:

const rects = range.getClientRects();
const lastRect = rects[rects.length - 1];

const virtualElement = {
  getBoundingClientRect() {
    return lastRect;
  }
};

Альтернатива — центрирование по всему диапазону:

const rect = range.getBoundingClientRect();

const virtualElement = {
  getBoundingClientRect() {
    return {
      ...rect,
      x: rect.left + rect.width / 2,
      y: rect.top + rect.height / 2,
      width: 0,
      height: 0
    };
  }
};

Такой подход удобен для контекстных панелей.


Обновление позиции при изменениях

Выделение может смещаться:

  • при прокрутке
  • при изменении размеров окна
  • при изменении DOM

Для автоматического обновления используется autoUpdate:

import { autoUpdate } from '@floating-ui/dom';

const cleanup = autoUpdate(
  virtualElement,
  floatingElement,
  () => {
    computePosition(virtualElement, floatingElement).then(({ x, y }) => {
      Object.assign(floatingElement.style, {
        left: `${x}px`,
        top: `${y}px`
      });
    });
  }
);

Реакция на изменение выделения

Отслеживание изменений:

document.addEventListener('selectionchange', () => {
  const selection = window.getSelection();

  if (!selection.rangeCount || selection.isCollapsed) {
    floatingElement.style.display = 'none';
    return;
  }

  floatingElement.style.display = 'block';

  // обновление позиции
});

Обработка пустого выделения

Важно учитывать состояние, когда:

  • выделение снято
  • выделение свернуто (курсор)

Проверка:

if (selection.isCollapsed) {
  // скрыть UI
}

Ограничение области позиционирования

Иногда требуется ограничить позиционирование внутри контейнера:

import { shift, limitShift } from '@floating-ui/dom';

middleware: [
  shift({
    limiter: limitShift()
  })
]

Проблемы и особенности браузеров

1. Нестабильные rects

  • В некоторых браузерах getClientRects() может возвращать неожиданные значения при inline-элементах.

2. Масштабирование

  • При zoom координаты могут быть дробными.

3. iframe

  • Выделение внутри iframe требует отдельной обработки координат.

Продвинутые сценарии

Контекстное меню форматирования

  • отображается над выделением
  • центрируется по диапазону
  • учитывает многострочность

Inline toolbar

  • закрепляется над последней строкой
  • использует getClientRects()

Подсказки (tooltips)

  • появляются рядом с курсором
  • используют минимальный прямоугольник

Оптимизация производительности

  • избегать частых вызовов computePosition
  • использовать autoUpdate вместо ручных обработчиков
  • кэшировать Range, если выделение не меняется

Архитектурный подход

  1. Получение выделения (Selection)
  2. Преобразование в виртуальный элемент
  3. Применение middleware
  4. Автоматическое обновление позиции
  5. Управление состоянием отображения

Минимальный рабочий пример

const floating = document.querySelector('#tooltip');

document.addEventListener('selectionchange', () => {
  const selection = window.getSelection();

  if (!selection.rangeCount || selection.isCollapsed) {
    floating.style.display = 'none';
    return;
  }

  const range = selection.getRangeAt(0);

  const virtualElement = {
    getBoundingClientRect: () => range.getBoundingClientRect()
  };

  floating.style.display = 'block';

  computePosition(virtualElement, floating, {
    middleware: [offset(8), flip(), shift()]
  }).then(({ x, y }) => {
    Object.assign(floating.style, {
      left: `${x}px`,
      top: `${y}px`
    });
  });
});

Ключевые идеи

  • выделение текста — это динамическая геометрия, а не фиксированный элемент
  • виртуальные элементы позволяют интегрировать Selection API с Floating UI
  • getClientRects() обеспечивает точность при многострочных выделениях
  • middleware формирует поведение позиционирования
  • autoUpdate решает проблему синхронизации с DOM

Такая комбинация делает Floating UI универсальным инструментом для построения сложных интерфейсов редактирования и взаимодействия с текстом.