fix(geocheck): скелетон до отрисовки отчёта и сброс зума на новом отчёте

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
c0mrade
2026-08-19 17:58:54 +03:00
parent 5db1ffccf6
commit 5c3031f5f5

View File

@@ -1,4 +1,4 @@
import { useLayoutEffect, useRef, useState } from 'react'; import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch'; import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
import { MinusIcon, PlusIcon, ResetIcon } from '@/components/icons'; import { MinusIcon, PlusIcon, ResetIcon } from '@/components/icons';
@@ -12,6 +12,9 @@ import { MinusIcon, PlusIcon, ResetIcon } from '@/components/icons';
const READABLE_WIDTH = 760; const READABLE_WIDTH = 760;
/** Масштаб 1 — отчёт вписан в ширину области; ниже опускаться незачем. */ /** Масштаб 1 — отчёт вписан в ширину области; ниже опускаться незачем. */
/** Ширины строк-заглушек, повторяющие ритм отчёта: заголовок, строки, пробел. */
const SKELETON_ROWS = [42, 88, 80, 84, 30, 70, 76, 82, 64, 28, 86, 74, 80, 68];
const SCALE_MIN = 1; const SCALE_MIN = 1;
const SCALE_MAX = 8; const SCALE_MAX = 8;
@@ -37,6 +40,27 @@ export function GeoCheckImageViewer({ src, alt, fullscreen }: GeoCheckImageViewe
const { t } = useTranslation(); const { t } = useTranslation();
const hostRef = useRef<HTMLDivElement>(null); const hostRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0); 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(() => { useLayoutEffect(() => {
const host = hostRef.current; const host = hostRef.current;
@@ -57,13 +81,27 @@ export function GeoCheckImageViewer({ src, alt, fullscreen }: GeoCheckImageViewe
return ( return (
<div ref={hostRef} className="relative h-full"> <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 && ( {width > 0 && (
<TransformWrapper <TransformWrapper
// Пересобираем, когда меняется нужный стартовый масштаб: полный экран, // Пересобираем, когда меняется нужный стартовый масштаб: полный экран,
// поворот телефона, разворот окна. Ключ огрублён до десятых — пока // поворот телефона, разворот окна. Ключ огрублён до десятых — пока
// отчёт и так вписывается (масштаб 1), перетаскивание окна мышью // отчёт и так вписывается (масштаб 1), перетаскивание окна мышью
// ничего не сбрасывает. // ничего не сбрасывает.
key={`${fullscreen}-${Math.round(initialScale * 10)}`} // `src` в ключе: перезапуск проверки даёт новый отчёт, и он должен
// открыться в исходном масштабе, а не унаследовать зум и сдвиг от
// предыдущего.
key={`${srcId}-${fullscreen}-${Math.round(initialScale * 10)}`}
centerOnInit={false} centerOnInit={false}
disablePadding disablePadding
doubleClick={{ mode: 'toggle' }} doubleClick={{ mode: 'toggle' }}
@@ -81,36 +119,45 @@ export function GeoCheckImageViewer({ src, alt, fullscreen }: GeoCheckImageViewe
// иначе браузер перехватит их под прокрутку. // иначе браузер перехватит их под прокрутку.
wrapperClass="!h-full !w-full cursor-grab touch-none active:cursor-grabbing" 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} /> <img
alt={alt}
className="block w-full select-none"
draggable={false}
src={src}
onLoad={() => setLoadedSrc(src)}
onError={() => setLoadedSrc(src)}
/>
</TransformComponent> </TransformComponent>
<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"> {loaded && (
<button <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">
type="button" <button
onClick={() => zoomOut()} type="button"
className={controlButton} onClick={() => zoomOut()}
aria-label={t('admin.remnawave.geoCheck.zoomOut', 'Zoom out')} className={controlButton}
> aria-label={t('admin.remnawave.geoCheck.zoomOut', 'Zoom out')}
<MinusIcon className="h-4 w-4" /> >
</button> <MinusIcon className="h-4 w-4" />
<button </button>
type="button" <button
onClick={() => zoomIn()} type="button"
className={controlButton} onClick={() => zoomIn()}
aria-label={t('admin.remnawave.geoCheck.zoomIn', 'Zoom in')} className={controlButton}
> aria-label={t('admin.remnawave.geoCheck.zoomIn', 'Zoom in')}
<PlusIcon className="h-4 w-4" /> >
</button> <PlusIcon className="h-4 w-4" />
<span aria-hidden className="mx-0.5 h-5 w-px bg-dark-100/15" /> </button>
<button <span aria-hidden className="mx-0.5 h-5 w-px bg-dark-100/15" />
type="button" <button
onClick={() => resetTransform()} type="button"
className={controlButton} onClick={() => resetTransform()}
aria-label={t('admin.remnawave.geoCheck.zoomReset', 'Reset zoom')} className={controlButton}
> aria-label={t('admin.remnawave.geoCheck.zoomReset', 'Reset zoom')}
<ResetIcon className="h-4 w-4" /> >
</button> <ResetIcon className="h-4 w-4" />
</div> </button>
</div>
)}
</> </>
)} )}
</TransformWrapper> </TransformWrapper>