diff --git a/src/components/admin/remnawave/GeoCheckImageViewer.tsx b/src/components/admin/remnawave/GeoCheckImageViewer.tsx index 253cffc..4775487 100644 --- a/src/components/admin/remnawave/GeoCheckImageViewer.tsx +++ b/src/components/admin/remnawave/GeoCheckImageViewer.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from 'react'; +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'; @@ -12,6 +12,9 @@ import { MinusIcon, PlusIcon, ResetIcon } from '@/components/icons'; 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; @@ -37,6 +40,27 @@ export function GeoCheckImageViewer({ src, alt, fullscreen }: GeoCheckImageViewe const { t } = useTranslation(); const hostRef = useRef(null); const [width, setWidth] = useState(0); + // Отчёт весит сотни килобайт вместе со встроенным шрифтом, и между приходом + // данных и первой отрисовкой есть заметная пауза. До неё показываем + // скелетон, а не пустую тёмную коробку с кнопками зума. + // + // Готовность хранится как «какая картинка отрисована», а не флагом: тогда + // новый отчёт автоматически считается незагруженным, без сбрасывающего + // эффекта. + const [loadedSrc, setLoadedSrc] = useState(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; @@ -57,13 +81,27 @@ export function GeoCheckImageViewer({ src, alt, fullscreen }: GeoCheckImageViewe return (
+ {!loaded && ( +
+ {SKELETON_ROWS.map((w) => ( +
+ ))} +
+ )} {width > 0 && ( - {alt} + {alt} setLoadedSrc(src)} + onError={() => setLoadedSrc(src)} + /> -
- - - - -
+ {loaded && ( +
+ + + + +
+ )} )}