GrImageViewer

  • ядро
  • Слои
  • API · 26 props
  • ⌨ клавиши

Берут, когда картинку нужно рассмотреть.

import { GrImageViewer } from '@feugene/granularity/components/GrImageViewer'

Обзор

Когда брать

  • картинку нужно рассмотреть — скан документа, чертёж, фотография товара: масштаб, поворот и панорамирование уже есть;
  • изображений несколько — urlList листается стрелками и свайпом, индекс управляем через v-model;
  • исходник можно скачать — showDownload даёт кнопку рядом с масштабом;
  • работа идёт с клавиатуры — весь набор действий имеет клавиши и подписи.

Когда взять другое

НужноБерите
Показать изображение в потоке страницыобычный <img> или GrCard
Аватар или миниатюраGrAvatar
Своя раскладка поверх страницыGrModal
Выбрать и загрузить файлGrFileUpload
Галерея-карусель в потоке страницыGrCarousel

Playground контролы выводятся из API, а не пишутся руками

Сцена

Окно откроется поверх страницы с пропами, заданными справа.

0 изменено· состояние — в адресе

Примеры 3 у каждого — живое демо и его код

  • План этажа, который читают в восемь раз крупнее

    Номера столов на плане мелкие, и прочитать их помогает просмотрщик: max-scale поднимает предел до восьми, zoom-rate делает шаг крупнее, а draggable позволяет двигать даже вписанный план. Колесо увеличивает в точку под курсором, а «1:1» ведёт к реальным пикселям, а не к «вписанному».

    Your desk tomorrow: 4.17Level 4, east wing · two rows from the lifts
  • Проверка сканов документов

    Вторая страница пришла боком: поворот встроен, а своя кнопка проверяющего встаёт рядом через toolbar-actions, не заменяя тулбар. Слот отдаёт кадр — индекс, alt, поворот, — поэтому «Запросить новый скан» называет страницу, о которой речь.

    Identity check · Jonas Weber2 pages uploaded today

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
ariaLabelstring | undefinedundefinedДоступное имя слоя. Модальный диалог без имени — нарушение `aria-dialog-name`: диктор объявит «диалог» и замолчит.
prevLabelstring | undefinedundefinedi18n: aria-label кнопки «предыдущее изображение».
nextLabelstring | undefinedundefinedi18n: aria-label кнопки «следующее изображение».
emptyTextstring | undefinedundefinedi18n: текст в пустом состоянии (нет изображений).
closeLabelstring | undefinedundefinedi18n: aria-label кнопки закрытия.
showProgressboolean | undefinedfalseСчётчик «3 / 12» в шапке. По умолчанию `false`.
initialIndexnumber | undefined0Кадр, с которого открывается просмотрщик; смена пропа у открытого переключает кадр. По умолчанию `0`.
zoomRatenumber | undefined1.2Множитель одного шага зума — кнопками и с клавиатуры. По умолчанию `1.2`.
minScalenumber | undefined0.5Нижний предел масштаба; `1` — кадр вписан в окно. По умолчанию `0.5`.
maxScalenumber | undefined5Верхний предел масштаба относительно вписанного кадра. По умолчанию `5`.
hideOnClickModalboolean | undefinedfalseЗакрывать по клику на подложку вокруг кадра. По умолчанию `false`.
closeOnPressEscapeboolean | undefinedtrueЗакрывать по `Esc`; открытый поверх модалки закрывает себя, а не её. По умолчанию `true`.
showZoomValueboolean | undefinedtrueМасштаб в процентах в шапке; 100% — кадр вписан в окно. По умолчанию `true`.
wheelZoomboolean | undefinedtrueВключает масштабирование колесом мыши / жестом на трекпаде. По умолчанию включено.

Слоты

SlotTypeОписание
toolbarGrImageViewerSlotPropsПанель инструментов целиком вместо встроенной.
toolbar-actionsGrImageViewerSlotPropsСвои кнопки рядом со встроенными — поворот, скачивание, печать.

События

EventСигнатураОписание
close(event: "close"): void—
update:modelValue(event: "update:modelValue", value: boolean): void—
change(event: "change", newIndex: number): void—
rotate(event: "rotate", deg: number): void—
download(event: "download", payload: { src: string; alt: string; index: number; }): void—

Методы

MethodsСигнатураОписание
close() => voidЗакрыть просмотрщик, как крестиком: шлёт `update:modelValue` и `close`.
prev() => voidПредыдущий кадр; с первого переходит на последний.
next() => voidСледующий кадр; с последнего переходит на первый.
zoomIn() => voidУвеличить в `zoomRate` раз, не выше `maxScale`.
zoomOut() => voidУменьшить в `zoomRate` раз, не ниже `minScale`.
zoomToNatural() => voidМасштаб «один к одному»: реальные 100%, а не номинальные.
reset() => voidВернуть кадру исходный вид: масштаб, поворот и сдвиг.
rotateLeft() => voidПовернуть на 90° против часовой стрелки.
rotateRight() => voidПовернуть на 90° по часовой стрелке.
download() => voidСкачать текущий кадр — то же, что делает кнопка тулбара.

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrImageViewerItem, GrImageViewerSource, GrImageViewerProps } from '@feugene/granularity/components/GrImageViewer'

Файлdist/types/components/GrImageViewer/GrImageViewer.vue.d.ts

GrImageViewerItemtype

Кадр просмотрщика: только адрес или адрес с альтернативным текстом.

type GrImageViewerItem = {
    src: string;
    alt?: string;
};
GrImageViewerSourcetype
type GrImageViewerSource = string | GrImageViewerItem;
GrImageViewerPropsinterface
interface GrImageViewerProps {
    /** Открыт ли просмотрщик (`v-model`). Закрывается он сам — крестиком, `Esc` или подложкой. */
    modelValue: boolean;
    /**
     * Кадры. Строка — только адрес; объект `{ src, alt }` даёт изображению
     * альтернативный текст: без него просмотрщик пуст для незрячего
     * пользователя, а придумать текст за потребителя компонент не может.
     */
    urlList: GrImageViewerSource[];
    /**
     * Кадр, с которого открывается просмотрщик; смена пропа у открытого переключает кадр.
     * По умолчанию `0`.
     */
    initialIndex?: number;
    /** Множитель одного шага зума — кнопками и с клавиатуры. По умолчанию `1.2`. */
    zoomRate?: number;
    /** Нижний предел масштаба; `1` — кадр вписан в окно. По умолчанию `0.5`. */
    minScale?: number;
    /** Верхний предел масштаба относительно вписанного кадра. По умолчанию `5`. */
    maxScale?: number;
    /** Закрывать по клику на подложку вокруг кадра. По умолчанию `false`. */
    hideOnClickModal?: boolean;
    /** Закрывать по `Esc`; открытый поверх модалки закрывает себя, а не её. По умолчанию `true`. */
    closeOnPressEscape?: boolean;
    /** Счётчик «3 / 12» в шапке. По умолчанию `false`. */
    showProgress?: boolean;
    /** Масштаб в процентах в шапке; 100% — кадр вписан в окно. По умолчанию `true`. */
    showZoomValue?: boolean;
    /** Включает масштабирование колесом мыши / жестом на трекпаде. По умолчанию включено. */
    wheelZoom?: boolean;
    /** Включает перетаскивание (pan) картинки мышью. При наведении курсор «рука». По умолчанию выключено. */
    draggable?: boolean;
    /**
     * Имя CSS-переменной слоя — escape-hatch мимо `--gr-z-modal`. Сырое число
     * компонент не принимает: слой задаётся шкалой, см. `docs/z-index.md`.
     */
    zIndexVar?: string;
    /** i18n: aria-label кнопки закрытия. */
    closeLabel?: string;
    /** i18n: aria-label кнопки «предыдущее изображение». */
    prevLabel?: string;
    /** i18n: aria-label кнопки «следующее изображение». */
    nextLabel?: string;
    /** i18n: aria-label кнопки «увеличить». */
    zoomInLabel?: string;
    /** i18n: aria-label кнопки «уменьшить». */
    zoomOutLabel?: string;
    /** i18n: aria-label кнопки «сбросить масштаб». */
    resetZoomLabel?: string;
    /** i18n: aria-label кнопки «до натурального размера». */
    zoomToNaturalLabel?: string;
    /** i18n: aria-label кнопки «повернуть влево». */
    rotateLeftLabel?: string;
    /** i18n: aria-label кнопки «повернуть вправо». */
    rotateRightLabel?: string;
    /** Показывать кнопку скачивания текущего кадра. */
    showDownload?: boolean;
    /** i18n: aria-label кнопки «скачать». */
    downloadLabel?: string;
    /** i18n: текст в пустом состоянии (нет изображений). */
    emptyText?: string;
    /**
     * Доступное имя слоя. Модальный диалог без имени — нарушение
     * `aria-dialog-name`: диктор объявит «диалог» и замолчит.
     */
    ariaLabel?: string;
}
GrImageViewerEmitsinterface
interface GrImageViewerEmits {
    /** Просмотрщик просит закрыться (`v-model`): приходит только с `false`. */
    (e: 'update:modelValue', value: boolean): void;
    /**
     * Закрыт изнутри — крестиком, `Esc`, подложкой или методом `close`. Внешний сброс модели
     * его не шлёт.
     */
    (e: 'close'): void;
    /** Показан другой кадр. */
    (e: 'change', newIndex: number): void;
    /** Кадр повёрнут; payload — накопленный угол в градусах с шагом 90, без приведения к 0–360. */
    (e: 'rotate', deg: number): void;
    /** Нажата кнопка скачивания. Само скачивание компонент уже запустил. */
    (e: 'download', payload: {
        src: string;
        alt: string;
        index: number;
    }): void;
}

Файлdist/types/components/GrImageViewer/config.d.ts

grImageViewerConfigconst
const grImageViewerConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrImageViewer">;

Файлdist/types/components/GrImageViewer/safelist.d.ts

grImageViewerSafelistconst
const grImageViewerSafelist: string[];

Файлdist/types/components/GrImageViewer/index.d.ts

GrImageViewerInstancetype
type GrImageViewerInstance = ComponentExposed<typeof GrImageViewerComponent>;

Доступность

Клавиши

Esc

КлавишаДействие
Escзакрыть

Использование из прозы библиотеки

Кадры: строка или { src, alt }

urlList принимает оба варианта, в том числе вперемешку:

vue
<GrImageViewer
  v-model="open"
  :url-list="[
    { src: '/photos/roof.jpg', alt: 'Крыша с высоты' },
    '/photos/plan.png',
  ]"
/>

Строка задаёт только адрес — alt у такого кадра пустой. Придумать текст за потребителя компонент не может, но и молча подставлять имя файла не должен: для незрячего пользователя «roof.jpg» не описание. Объектная форма — способ дать описание там, где оно есть.

alt приходит и в слот тулбара (src, alt в slot-props) — тулбар может показать подпись рядом с кадром.

Доступное имя и объявление смены кадра

Слой объявляет себя диалогом, поэтому у него всегда есть имя: из локали (gr.imageViewer.label) или из пропа ariaLabel. Диалог без имени — нарушение aria-dialog-name.

Смена кадра уходит в общий живой регион пакета (announcer.md): «Изображение 2 из 5» (gr.imageViewer.position). При открытии позиция не объявляется — её скажет имя диалога.

Регион лежит вне просмотрщика, хотя тот накрывает страницу inert. Это работает благодаря пропуску по data-gr-live-region в стеке слоёв: inert выбрасывает поддерево из дерева доступности, и регион внутри него замолчал бы.

Счётчик showProgress остаётся чисто визуальным: он дублирует ту же информацию глазами.

Зум, панорамирование и жесты

Колесо и жест трекпада увеличивают в точку под курсором, а не от центра: иначе до нужного угла увеличенного кадра пришлось бы добираться отдельным перетаскиванием.

Смещение кадра ограничено его собственным переполнением — насколько картинка вылезла за область просмотра, настолько её и можно тянуть. Утащить изображение в пустоту и потерять его нельзя, а при возврате к масштабу 1 смещение обнуляется само. Поворот на 90° меняет оси местами, и границы считаются по повёрнутому кадру.

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

У жестов два владельца, и это видно в поведении. Мышь и перо ведёт общий примитив useDragGesture: слушатели на window, поэтому отпускание ловится и за пределами картинки, и когда кадр сменился прямо посреди жеста. Касание ведёт свой трекер — у него многопальцевая модель: pinch двумя пальцами, свайп для листания, а на увеличенном кадре одиночный жест тянет.

Обрыв жеста (браузер забрал указатель — системный жест, звонок, потеря окна) заканчивает перетаскивание, оставляя кадр там, куда его довели, и не засчитывается как свайп: прерванный жест не листает.

Номинальный масштаб против реального

scale номинальный: единица — это «кадр вписан в окно» (object-contain), а не натуральный размер. У фотографии 4752 px, вписанной в окно шириной ~1000 px, номинальные 100% — это реальные 21%: пикселей на таком масштабе не видно вовсе.

Отсюда две разные кнопки тулбара. «100%» сбрасывает трансформацию и возвращает вписанный кадр; «1:1» (actions.zoomToNatural) доводит до реальных 100% — пиксель картинки на пиксель экрана. Считать это потребителю самому не нужно, хотя данные для расчёта слот отдаёт (naturalWidth, renderedWidth, realScale).

Потолок maxScale действует и на «1:1»: он ограничивает зум сознательно, и натуральный размер — не повод его обойти. Дефолтные maxScale: 5 для крупного кадра до реальных 100% не дотягивают (нужное номинальное приближение — это naturalWidth / renderedWidth при scale = 1, у фотографий это 5–12×), поэтому крупным изображениям потолок задаётся пропом. Обратное тоже работает: если кадр меньше своего места на экране, «1:1» уменьшит его — с оглядкой на minScale.

Сенсорные жесты работают на тех же pointer-событиях:

ЖестЧто делает
Два пальцаМасштаб по расстоянию между ними, якорь — середина
Палец на вписанном кадре, горизонтальноЛистание кадров
Палец на увеличенном кадреПеретаскивание

Порог свайпа — 60px и преобладание горизонтали: вертикальное движение листать не должно, пользователь целился не туда. Картинка объявлена touch-action: none, иначе браузер забирает жест себе и уводит страницу вместо зума.

Скачивание

showDownload добавляет в тулбар кнопку: она скачивает текущий кадр (<a download>) и эмитит download с { src, alt, index } — для аналитики.

Кросс-доменный адрес браузер скачает не всегда: атрибут download для чужого источника игнорируется, и файл откроется в новой вкладке. Там, где нужны подписанные ссылки или свой запрос, кнопку выключают (showDownload: false) и кладут свою в слот #toolbar-actions — он получает те же slot-props, включая actions.download.

Предзагрузка соседних кадров

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

Изменение списка на лету

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

Слой

--gr-z-modal, как у остальных модальных оверлеев. zIndexVar подменяет переменную слоя своей — тот же escape-hatch, что у useFloating и GrLoading. Сырого числа компонент не принимает: слой задаётся шкалой, см. ../z-index.md.

Точное значение — calc(var(<токен>) + глубина): свою глубину среди открытых модальных слоёв просмотрщик берёт из стека, поэтому поверх окна он оказывается и по отрисовке, а не только по Esc. С zIndexVar это работает так же — глубина прибавляется к подменённой переменной.

Esc, inert для нижних слоёв и возврат фокуса идут через общий стек (useOverlayLayer), поэтому просмотрщик поверх модалки закрывает себя, а не её.

Токены хрома

Панель, кнопки и подложка красятся покомпонентными токенами:

ТокенЧто красит
--gr-image-viewer-scrimподложка под изображением
--gr-image-viewer-chrome-bgфон кнопок и панели инструментов
--gr-image-viewer-chrome-bg-hoverфон кнопки под курсором
--gr-image-viewer-chrome-bg-softмягкая подсветка кнопок тулбара
--gr-image-viewer-chrome-fgиконки и текст хрома
--gr-image-viewer-chrome-fg-mutedтекст пустого состояния
--gr-image-viewer-chrome-brdрамки и разделители
--gr-image-viewer-ringкольцо фокуса

Значения одинаковы в светлой и тёмной теме намеренно: рядом с фотографией светлая панель спорит с содержимым и подсвечивает края кадра. Токены нужны не для смены темы, а чтобы потребитель мог перекрасить хром под свой продукт.

Императивный API
vue
<GrImageViewer ref="viewer" v-model="open" :url-list="images" />

viewer.value отдаёт close, prev, next, zoomIn, zoomOut, zoomToNatural, reset, rotateLeft, rotateRight — тот же набор, что получает слот тулбара. Открытия среди них нет: оно принадлежит v-model, вторая точка входа рассинхронизировала бы состояние с моделью.

События
СобытиеКогда
update:modelValueоткрытие/закрытие
closeзакрытие
changeпоказан другой кадр, аргумент — новый индекс
rotateповорот, аргумент — накопленный угол в градусах