Работа с текстовыми выделениями в интерфейсе требует высокой точности позиционирования. В отличие от стандартных элементов (кнопок, блоков), выделение текста может:
Библиотека 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 не требует наличия реального 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:
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
};
}
};
Такой подход удобен для контекстных панелей.
Выделение может смещаться:
Для автоматического обновления используется
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. Масштабирование
3. iframe
getClientRects()computePositionautoUpdate вместо ручных обработчиковRange, если выделение не меняетсяSelection)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`
});
});
});
getClientRects() обеспечивает точность при
многострочных выделенияхautoUpdate решает проблему синхронизации с DOMТакая комбинация делает Floating UI универсальным инструментом для построения сложных интерфейсов редактирования и взаимодействия с текстом.