GrFilePreview

Пакет: @feugene/granularityядроГруппа: Данные

Берут, когда лента вложений.

Когда брать

  • лента вложений — чеки, договоры, выгрузки: в наборе вперемешку картинки и документы, и по одному правилу их не покажешь;
  • тип файла заранее неизвестен — контроллер отдаёт варианты без фильтра, и <img> на PDF рисует битую иконку;
  • превью открывает просмотрщик — плитка эмитит click, окно показывает потребитель;
  • плиток на странице десяток — ленивая загрузка и держащее место соотношение сторон уже внутри.

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

НужноБерите
Выбрать файл и отправить его на серверGrFileUpload
Файл как значение поля формыGrFormFile
Открыть картинку во весь экранGrImageViewer
Аватар человека или сущностиGrAvatar
Показать содержимое файла текстомGrCodeBlock (пакет @feugene/granularity-code)

Тип решает `mime`, а не расширение

Расширение врёт: .dat у выгрузки, .pdf у переименованного архива. Бэкенд отдаёт настоящий тип — берётся он.

Видов шесть: картинка, PDF, документ, таблица, архив и неопознанное. Картинка — единственный, который рисуется <img>; остальные получают иконку и подпись. Пустой mime, application/octet-stream и незнакомый тип дают заглушку, а не пустоту: «типа нет» — обычное состояние строки в БД, а не повод показать дыру.

text/csv разбирается как таблица, а не как текст: открывается он таблицей.

Классификация публичная: fileKindOf(mime) и isPreviewableKind(kind) отдаются из пакета. Она нужна снаружи ровно потому, что плитка просмотрщик не открывает (см. «Границы»): решать, какие файлы отдать в GrImageViewer, приходится потребителю — и без этих функций он заводит свой mime.startsWith('image/'), который расходится с плиткой на первом же новом типе.

Заглушка вместо сломанной картинки

Три пути ведут в одну и ту же заглушку, и это осознанно:

  • тип не картинка;
  • src пуст;
  • загрузка сорвалась (onerror) — превью могло исчезнуть с диска.

В последнем случае значок другой — «изображение не открылось», а не «это файл»: разница между «файл такого рода» и «картинка была, но не доехала» видна сразу. Новый src ошибку не наследует.

`alt` не выдумывается

Задано name — оно и становится alt. Не задано — картинка декоративна (alt=""), потому что придуманное компонентом описание диктор прочитает как факт, и это хуже пустого.

У заглушки name печатается подписью под иконкой, а сама иконка скрыта от диктора: текст уже сказал всё.

Интерактивная плитка берёт имя из содержимого — alt картинки или подписи. Если содержимое безымянно, имя задаётся ariaLabel: кнопка без имени для скринридера пуста.

Плитка кликабельна только когда её попросили

Без clickable, href и as это <div>: картинка, а не контрол. Она не занимает остановку Tab и не получает курсор — пустая остановка хуже её отсутствия.

Порядок выбора тега — as<a href><button clickable><div>, как у GrCard и GrLink. Компонент-ссылка (Link от Inertia, RouterLink) получает href; строковый тег, кроме a, — нет.

Размер и пропорции

tileSize — ступень канонической шкалы либо число: плитка 96px в ленте вложений в четыре ступени не укладывается. Числовой escape-hatch здесь по той же причине, что диаметр у GrAvatar.

ratio держит место до загрузки. Без него ряд плиток прыгает, когда картинки доезжают вразнобой.

Место держит скелет, а не пустота

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

Картинка при этом остаётся в дереве и просто ждёт невидимой: убери её оттуда — браузер не начнёт загрузку, и состояние «грузится» не кончится никогда.

Границы

Компонент не грузит файлы, не открывает просмотрщик сам и не генерирует превью для PDF: отрисовать первую страницу документа в браузере — задача отдельной библиотеки, и её вес несоразмерен плитке. Нужна миниатюра PDF — готовьте её на сервере и передавайте в src как картинку.

Playground 4

Загружается…

Код
<GrFilePreview />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
ariaLabelstring | undefinedundefinedДоступное имя интерактивной плитки. Не задано — имя приходит из содержимого: `alt` картинки или подписи заглушки.
loading"lazy" | "eager" | undefinedundefined
namestring | null | undefinedundefinedИмя файла: доступное имя картинки и подпись заглушки.
srcstring | null | undefinedundefinedАдрес превью. Пусто — сразу заглушка по типу.
asstring | Component | undefinedundefinedСвой корневой тег (`RouterLink`, `Link` от Inertia). Сильнее `href`.
hrefstring | undefinedundefinedСсылка на оригинал — для не-картинок и для перехода мимо просмотрщика.
clickableboolean | undefinedfalseПлитка кликабельна и эмитит `click` — обычно чтобы открыть просмотрщик.
mimestring | null | undefinedundefinedMIME-тип. Он решает, картинка это или файл.
tileSizeGrSizeWithPx | undefinedundefinedСтупень канонической шкалы либо произвольная ширина в пикселях. Число — escape-hatch, как диаметр у GrAvatar: плитка 96px в ленте вложений в четыре ступени не укладывается.
ratioGrFilePreviewRatio | undefinedundefined

Events

EventTypeОписание
click[event: MouseEvent]

Примеры 3

Один ряд, шесть видов файлов

Тип решает mime, а не расширение. Картинка рисуется картинкой, остальное получает иконку вида: PDF, документ, таблица, архив. Пустой тип даёт заглушку, а не пустоту — «типа нет» это обычное состояние строки в БД.

contract.pdf
report.xlsx
sources.zip
notes.txt
export.dat

Kinds
<script setup lang="ts">
import { GrFilePreview } from '@feugene/granularity'

// Картинка нарисована на месте, а не взята с внешнего хоста: демо снимается в
// визуальный эталон, и чужой сервер сделал бы снимок невоспроизводимым.
const thumbnail = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
    <rect width="200" height="200" fill="#dbeafe" />
    <path d="M0 150l60-50 45 38 40-30 55 42v50H0z" fill="#2563eb" opacity="0.3" />
    <circle cx="152" cy="52" r="22" fill="#2563eb" opacity="0.45" />
  </svg>
`)}`

// Ровно то, что отдаёт контроллер: варианты файла без фильтра по типу.
const files = [
  { name: 'receipt.png', mime: 'image/png', src: thumbnail },
  { name: 'contract.pdf', mime: 'application/pdf', src: null },
  { name: 'report.xlsx', mime: 'application/vnd.ms-excel', src: null },
  { name: 'sources.zip', mime: 'application/zip', src: null },
  { name: 'notes.txt', mime: 'text/plain', src: null },
  // Тип бэкенд не проставил — обычное состояние строки в БД.
  { name: 'export.dat', mime: null, src: null },
]
</script>

<template>
  <div class="flex flex-wrap gap-3">
    <GrFilePreview
      v-for="file in files"
      :key="file.name"
      :src="file.src"
      :mime="file.mime"
      :name="file.name"
      tile-size="lg"
    />
  </div>
</template>

<img> на не-картинку рисует битую иконку. Ровно этот дефект и был у потребителя: контроллер отдавал варианты файла без фильтра по типу.

Плитка открывает просмотрщик и переживает битую ссылку

Плитка эмитит click — просмотрщик открывает потребитель: набор плиток и просмотр набора это разные состояния страницы. Третья ссылка битая: превью деградирует в заглушку с другим значком — «изображение не открылось», а не «это файл».

Viewer
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrFilePreview, GrImageViewer } from '@feugene/granularity'

// Картинки нарисованы на месте: демо попадает в визуальный эталон, а внешний
// хост сделал бы снимок зависящим от сети.
function receipt(hue: number): string {
  return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
      <rect width="400" height="400" fill="hsl(${hue} 90% 92%)" />
      <rect x="120" y="70" width="160" height="260" rx="8" fill="hsl(${hue} 70% 55%)" opacity="0.25" />
      <rect x="150" y="110" width="100" height="10" rx="5" fill="hsl(${hue} 70% 40%)" />
      <rect x="150" y="140" width="70" height="10" rx="5" fill="hsl(${hue} 70% 40%)" opacity="0.6" />
      <rect x="150" y="170" width="90" height="10" rx="5" fill="hsl(${hue} 70% 40%)" opacity="0.6" />
    </svg>
  `)}`
}

const files = [
  { name: 'receipt-01.jpg', mime: 'image/jpeg', src: receipt(210) },
  { name: 'receipt-02.jpg', mime: 'image/jpeg', src: receipt(150) },
  // Битая ссылка: превью исчезло с диска. Плитка деградирует в заглушку, а не
  // в сломанную картинку.
  { name: 'receipt-03.jpg', mime: 'image/jpeg', src: 'https://cdn.invalid/missing.jpg' },
  { name: 'act.pdf', mime: 'application/pdf', src: null },
]

// В просмотрщик уходят только картинки: у PDF смотреть нечего.
const images = computed(() => files.filter(file => file.mime?.startsWith('image/')))

const viewerOpen = ref(false)
const viewerIndex = ref(0)

function open(name: string): void {
  viewerIndex.value = Math.max(0, images.value.findIndex(file => file.name === name))
  viewerOpen.value = true
}
</script>

<template>
  <div class="flex flex-wrap gap-3">
    <template v-for="file in files" :key="file.name">
      <!--
        Картинка открывает просмотрщик, остальное — ссылка на оригинал.
        Решение принимает потребитель: плитка только сообщает о клике.
      -->
      <GrFilePreview
        v-if="file.mime?.startsWith('image/')"
        :src="file.src"
        :mime="file.mime"
        :name="file.name"
        clickable
        :aria-label="`Открыть ${file.name}`"
        @click="open(file.name)"
      />
      <GrFilePreview
        v-else
        :mime="file.mime"
        :name="file.name"
        href="#"
      />
    </template>

    <GrImageViewer
      v-model="viewerOpen"
      :url-list="images.map(file => file.src).filter((src): src is string => src !== null)"
      :initial-index="viewerIndex"
    />
  </div>
</template>

Десяток плиток, каждая держит своё место при загрузке

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

Grid
<script setup lang="ts">
import { GrFilePreview } from '@feugene/granularity'

// Лента вложений к заявке: одна ссылка на файл, ничего больше. Картинки
// нарисованы на месте, а не взяты с внешнего хоста: демо снимается в визуальный
// эталон, и чужой сервер сделал бы снимок зависящим от сети.
function scan(index: number): string {
  const hue = (index * 29) % 360

  return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160">
      <rect width="160" height="160" fill="hsl(${hue} 85% 90%)" />
      <path d="M0 120l45-38 34 29 30-23 51 32v40H0z" fill="hsl(${hue} 70% 45%)" opacity="0.35" />
      <circle cx="120" cy="42" r="16" fill="hsl(${hue} 70% 45%)" opacity="0.5" />
    </svg>
  `)}`
}

const attachments = Array.from({ length: 12 }, (_, index) => ({
  name: `scan-${String(index + 1).padStart(2, '0')}.jpg`,
  mime: 'image/jpeg',
  src: scan(index),
}))
</script>

<template>
  <!--
    Пока картинка не доехала, плитка показывает скелет, а не пустой фон:
    «ещё грузится» и «у файла нет превью» — разные сообщения, и на дюжине
    плиток сразу видно, какое из них правда.
  -->
  <div class="flex flex-wrap gap-2">
    <GrFilePreview
      v-for="file in attachments"
      :key="file.name"
      :src="file.src"
      :mime="file.mime"
      :name="file.name"
      tile-size="xs"
      ratio="1:1"
    />
  </div>
</template>

Картинка при этом остаётся в разметке и просто ждёт невидимой: убери её на время загрузки — браузер не начнёт качать, и состояние «грузится» не кончится никогда.

Документация компонентаВсе компоненты