Files
bedolaga-cabinet/src/components/admin/remnawave/GeoCheckImageViewer.tsx
c0mrade 5c3031f5f5 fix(geocheck): скелетон до отрисовки отчёта и сброс зума на новом отчёте
Отчёт весит сотни килобайт вместе со встроенным шрифтом, поэтому между
приходом данных и первой отрисовкой есть заметная пауза. Всё это время на
экране была пустая тёмная коробка, зато с готовыми кнопками зума, которым
нечего масштабировать. Теперь до отрисовки показывается скелетон, повторяющий
ритм отчёта, а панель зума появляется вместе с картинкой.

Перезапуск проверки наследовал зум и сдвиг от предыдущего отчёта. Новый отчёт
должен открываться в исходном масштабе, поэтому его подпись входит в ключ
просмотрщика.

Подпись — хеш всей строки, посчитанный один раз на отчёт. Первая попытка
брала длину и хвост base64, и этого оказалось мало: отчёты одного узла
кончаются одинаково, подписи совпадали, зум не сбрасывался. Поймано
проверкой, а не рассуждением.

Готовность хранится как «какая картинка отрисована», а не булевым флагом:
тогда новый отчёт автоматически считается незагруженным и сбрасывающий
эффект не нужен.

Проверено в браузере: при задержке картинки — скелетон есть, панели зума нет;
после загрузки наоборот. Зум со сдвигом после перезапуска возвращается к
исходному преобразованию, дважды подряд.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 17:58:54 +03:00

168 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
import { MinusIcon, PlusIcon, ResetIcon } from '@/components/icons';
/**
* Ширина в CSS-пикселях, при которой моноширинный отчёт ещё читается.
* Отчёт всегда заметно шире телефона, поэтому «вписать по ширине» на узком
* экране превращает его в серую рябь. Стартуем с масштаба, дающего примерно
* такую ширину, а вписать целиком всегда можно кнопкой сброса и жестом.
*/
const READABLE_WIDTH = 760;
/** Масштаб 1 — отчёт вписан в ширину области; ниже опускаться незачем. */
/** Ширины строк-заглушек, повторяющие ритм отчёта: заголовок, строки, пробел. */
const SKELETON_ROWS = [42, 88, 80, 84, 30, 70, 76, 82, 64, 28, 86, 74, 80, 68];
const SCALE_MIN = 1;
const SCALE_MAX = 8;
interface GeoCheckImageViewerProps {
src: string;
alt: string;
/** Смена режима меняет доступную ширину — просмотрщик пересобирается. */
fullscreen: boolean;
}
/**
* Просмотр широкого SVG-отчёта: зум, перетаскивание, пинч, двойной тап.
*
* Зум свой, а не браузерный: в `index.html` приложения стоит
* `user-scalable=no`, поэтому нативного пинча нет ни в мобильном вебе, ни в
* Mini App. Библиотека берёт на себя все жесты и работает одинаково от мыши,
* пальца и трекпада — так же это сделано в самой панели Remnawave.
*
* Колесо намеренно не масштабирует (`wheelDisabled`): иначе обычная прокрутка
* над отчётом превращалась бы в зум.
*/
export function GeoCheckImageViewer({ src, alt, fullscreen }: GeoCheckImageViewerProps) {
const { t } = useTranslation();
const hostRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
// Отчёт весит сотни килобайт вместе со встроенным шрифтом, и между приходом
// данных и первой отрисовкой есть заметная пауза. До неё показываем
// скелетон, а не пустую тёмную коробку с кнопками зума.
//
// Готовность хранится как «какая картинка отрисована», а не флагом: тогда
// новый отчёт автоматически считается незагруженным, без сбрасывающего
// эффекта.
const [loadedSrc, setLoadedSrc] = useState<string | null>(null);
const loaded = loadedSrc === src;
// Подпись отчёта для ключа. Длины и хвоста base64 недостаточно: отчёты
// одного узла кончаются одинаково и совпали бы по такой подписи, а зум
// тогда не сбрасывается. Полная строка в ключе — сотни килобайт на каждый
// рендер, поэтому считаем хеш один раз на отчёт.
const srcId = useMemo(() => {
let hash = 0;
for (let i = 0; i < src.length; i += 1) {
hash = (hash * 31 + src.charCodeAt(i)) | 0;
}
return `${src.length}:${hash}`;
}, [src]);
useLayoutEffect(() => {
const host = hostRef.current;
if (!host) return;
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
observer.observe(host);
setWidth(host.clientWidth);
return () => observer.disconnect();
}, []);
// Масштаб 1 = вписано по ширине. На широком экране этого достаточно, на
// узком — поднимаем до читаемого и позволяем панорамировать.
const initialScale = width > 0 ? Math.min(SCALE_MAX, Math.max(1, READABLE_WIDTH / width)) : 1;
const controlButton =
'flex h-9 w-9 items-center justify-center rounded-lg text-dark-200 transition-colors hover:bg-dark-100/10 active:scale-95';
return (
<div ref={hostRef} className="relative h-full">
{!loaded && (
<div className="absolute inset-0 z-10 space-y-2 p-4" aria-busy="true">
{SKELETON_ROWS.map((w) => (
<div
key={w}
className="h-3 animate-pulse rounded bg-dark-700/50"
style={{ width: `${w}%` }}
/>
))}
</div>
)}
{width > 0 && (
<TransformWrapper
// Пересобираем, когда меняется нужный стартовый масштаб: полный экран,
// поворот телефона, разворот окна. Ключ огрублён до десятых — пока
// отчёт и так вписывается (масштаб 1), перетаскивание окна мышью
// ничего не сбрасывает.
// `src` в ключе: перезапуск проверки даёт новый отчёт, и он должен
// открыться в исходном масштабе, а не унаследовать зум и сдвиг от
// предыдущего.
key={`${srcId}-${fullscreen}-${Math.round(initialScale * 10)}`}
centerOnInit={false}
disablePadding
doubleClick={{ mode: 'toggle' }}
initialScale={initialScale}
maxScale={SCALE_MAX}
minScale={SCALE_MIN}
trackPadPanning={{ disabled: false }}
wheel={{ wheelDisabled: true }}
>
{({ resetTransform, zoomIn, zoomOut }) => (
<>
<TransformComponent
contentStyle={{ width: '100%' }}
// touch-action: none — все жесты обрабатывает библиотека,
// иначе браузер перехватит их под прокрутку.
wrapperClass="!h-full !w-full cursor-grab touch-none active:cursor-grabbing"
>
<img
alt={alt}
className="block w-full select-none"
draggable={false}
src={src}
onLoad={() => setLoadedSrc(src)}
onError={() => setLoadedSrc(src)}
/>
</TransformComponent>
{loaded && (
<div className="absolute bottom-3 right-3 z-10 flex items-center gap-0.5 rounded-xl border border-dark-100/10 bg-dark-950/70 p-1 shadow-lg backdrop-blur-md">
<button
type="button"
onClick={() => zoomOut()}
className={controlButton}
aria-label={t('admin.remnawave.geoCheck.zoomOut', 'Zoom out')}
>
<MinusIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => zoomIn()}
className={controlButton}
aria-label={t('admin.remnawave.geoCheck.zoomIn', 'Zoom in')}
>
<PlusIcon className="h-4 w-4" />
</button>
<span aria-hidden className="mx-0.5 h-5 w-px bg-dark-100/15" />
<button
type="button"
onClick={() => resetTransform()}
className={controlButton}
aria-label={t('admin.remnawave.geoCheck.zoomReset', 'Reset zoom')}
>
<ResetIcon className="h-4 w-4" />
</button>
</div>
)}
</>
)}
</TransformWrapper>
)}
</div>
);
}