Масштабирование Canvas для High DPR: исправляем баг с содержимым
При разработке кастомного компонента Canvas на React вы столкнулись с типичной проблемой: после изменения масштаба (zoom браузера) размер самого canvas уменьшается, но нарисованное содержимое остаётся крупным. Это происходит из-за неполной синхронизации между физическими пикселями, логическими размерами и контекстом рисования. Разберём корень проблемы и предложим исправленную реализацию.
Почему содержимое не масштабируется вместе с canvas?
Ваш текущий код корректно обрабатывает devicePixelRatio (DPR) для HiDPI-экранов, но не учитывает повторный вызов ctx.scale() при каждом изменении размеров. Когда пользователь изменяет zoom (Ctrl+MouseWheel), браузер меняет размеры canvas через CSS, но внутренние атрибуты width и height и масштаб контекста остаются прежними. В результате:
- Canvas физически уменьшается (стили),
- Но контекст рисования всё ещё использует старый масштаб,
- Графика рисуется с прежними координатами и размерами, выходя за видимую область.
Решение - при каждом изменении размеров (включая zoom) сбрасывать трансформацию контекста и применять новый DPR.
Исправленный код компонента Canvas
Ниже приведён рабочий вариант с корректной обработкой масштабирования. Основные изменения: вызов ctx.setTransform() вместо ctx.scale() и перерисовка содержимого через callback.
import { useResize } from '@/hooks';
import { ComponentPropsWithoutRef, forwardRef, useEffect, useImperativeHandle, useRef, useCallback } from 'react';
export const Canvas = forwardRef<
HTMLCanvasElement,
ComponentPropsWithoutRef<'canvas'> & { onRedraw?: (ctx: CanvasRenderingContext2D) => void }
>((props, ref) => {
const { onRedraw, ...rest } = props;
const innerRef = useRef(null);
const canvasRef = (ref as React.RefObject) || innerRef;
const { width } = useResize();
useImperativeHandle(ref, () => canvasRef.current!, [canvasRef]);
const updateCanvasSize = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Сброс всех трансформаций и установка нового масштаба
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Вызов внешней функции перерисовки, если она передана
if (onRedraw) {
onRedraw(ctx);
}
}, [onRedraw]);
useEffect(() => {
updateCanvasSize();
}, [width, updateCanvasSize]);
return (
);
}); Ключевые улучшения
- Сброс трансформации:
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)заменяетctx.scale()и гарантирует, что каждый раз масштаб устанавливается заново, а не накапливается. - Callback перерисовки: пропс
onRedrawпозволяет перерисовывать графику после изменения размеров. Без него содержимое останется старым, даже если canvas изменился. - Динамические стили: в примере установлены
width: 100%, height: 100%, но вы можете задать любые размеры через CSS. Главное - не фиксировать их в пикселях внутри JS.
Проверка на разных устройствах
После внедрения исправления протестируйте компонент:
- На экранах с
devicePixelRatio2 или 3 (Retina). - При изменении zoom браузера (Ctrl+колесо мыши).
- При ресайзе окна -
useResizeуже отслеживает ширину, но убедитесь, что он корректно срабатывает.
Если графика всё ещё отображается некорректно, проверьте, что ваш код рисования использует координаты, соответствующие логическим пикселям (не умноженным на DPR).
Заключение
Проблема масштабирования canvas при zoom и High DPR решается правильным сбросом трансформации контекста и принудительной перерисовкой. Используйте setTransform вместо scale и передавайте функцию обновления графики. Это гарантирует, что содержимое всегда соответствует видимым размерам элемента.