GrImageCrop

Пакет: @feugene/granularity-mediaспутникГруппа: Прочее

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

Когда брать

  • аватар из загруженного файла — пользователь принёс фотографию произвольных пропорций, а профилю нужен квадрат;
  • обложка под фиксированное место — карточка товара или шапка раздела, где вёрстка рассчитана на одно соотношение сторон;
  • кадр из снимка камерыBlob с устройства приходит целиком, а показать нужно лицо, а не всю комнату;
  • уменьшение веса вложения — вырезанная область экспортируется в image/webp с заданным качеством, и на сервер уезжает килобайт вместо мегабайта.

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

НужноБерите
Показать картинку с зумом и листанием, не меняя еёGrImageViewer
Принять файл от пользователя: зона переноса, очередь, проверкиGrFileUpload / GrFormFile
Показать готовую аватарку в интерфейсеGrAvatar

Рамка неподвижна, двигается картинка

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

Следствие для потребителя: соотношение сторон кадра задаёт он, а не пользователь. aspectRatio — число (1, 16 / 9), потому что строку '16:9' пришлось бы разбирать в рантайме, и ошибка формата всплыла бы уже на экране.

Кадр считается в пикселях исходника

Без output результат получает размер захваченной области исходного файла, а не окна на экране. Окно почти всегда меньше картинки — вывод по нему молча ополовинил бы разрешение, и заметили бы это на чужом ретина-экране.

Нужен ровно заданный размер (аватар 256×256) — он указывается явно, и тогда drawImage масштабирует область под него.

`output` — габарит, а не точный размер

output.width без height — обычный заказ («аватар шириной 256»), и вторая сторона считается из соотношения захваченной области; взятая из исходника, она растянула бы картинку.

Обе стороны — рамка, в которую результат вписывается, сохраняя пропорции области. На типовом заказе (квадрат 256×256 из квадратной рамки) это ровно 256×256; отличие видно там, где соотношения разошлись.

Круг — это маска, а не форма результата

shape="circle" затемняет всё вне круга, чтобы пользователь видел, что попадёт в аватарку. Экспорт при этом остаётся прямоугольным: круглым изображение делает то место, где оно показывается (GrAvatar и его rounded), а PNG с прозрачными углами весит больше и не годится на подложке другого цвета.

Картинка с чужого домена ломает экспорт

Изображение запрашивается с crossOrigin="anonymous". Если сервер не отдал Access-Control-Allow-Origin, холст становится непригодным для чтения, и toBlob падает SecurityError — уже после того, как пользователь выбрал кадр. На экране до этого момента всё правильно, поэтому причину не угадать: компонент эмитит error и печатает в разработке предупреждение, которое называет происходящее прямо.

Практический вывод: картинки для кропа отдавайте со своего домена или со свойством CORS у хранилища.

Плавность выключена под пальцем

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

Границы

  • нет поворота и зеркала. Кадрирование и трансформация — разные задачи; поворот потребовал бы второй оси управления и своей клавиатуры;
  • не сжимает до целевого веса. output.quality — параметр кодека, а не бюджет в килобайтах: подбор качества под лимит остаётся за приложением;
  • один кадр за раз. Пакетная обработка галереи — сценарий загрузчика, а не этого компонента.

Установка

npm i @feugene/granularity-media

Импорт

import { GrImageCrop } from '@feugene/granularity-media/components/GrImageCrop'

API

API этого компонента ещё не посчитан: генератор витрины пока обходит только ядро. Пока его нет, справочник — в документации пакета.

Примеры 3

Avatar Preview

Avatar Preview
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, useTemplateRef } from 'vue'

import { GrAvatar, GrCard } from '@feugene/granularity'

/**
 * Один кадр — три файла, и каждый показан там, где он потом и появится.
 *
 * Приложения хранят аватар не одной картинкой: в шапку идёт крупный, в строку
 * списка — мелкий, и отдавать 256 px туда, где рисуется 24, значит возить лишние
 * килобайты на каждой строке. Размер задаёт `output`, а кадр остаётся тем же.
 *
 * Второе, что видно только так: главное сомнение при кадрировании — «а как это
 * будет смотреться маленьким». В кружке 24 px сразу заметно, что в кадр попало
 * лишнее.
 */
const source = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
  <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
    <rect width="1200" height="900" fill="#1e293b" />
    <circle cx="640" cy="380" r="180" fill="#fbbf24" />
    <rect x="470" y="560" width="340" height="340" rx="170" fill="#38bdf8" />
    <rect x="80" y="80" width="220" height="740" rx="24" fill="#34d399" fill-opacity="0.35" />
    <rect x="900" y="80" width="220" height="740" rx="24" fill="#f472b6" fill-opacity="0.35" />
  </svg>
`)}`

interface Variant {
  key: 'large' | 'medium' | 'small'
  label: string
  px: number
  url: string | null
  weight: number
}

const variants = ref<Variant[]>([
  { key: 'large', label: 'Крупный', px: 256, url: null, weight: 0 },
  { key: 'medium', label: 'Средний', px: 96, url: null, weight: 0 },
  { key: 'small', label: 'Мелкий', px: 32, url: null, weight: 0 },
])

const outputWidth = ref(256)
const cropper = useTemplateRef('cropper')

let pending: ReturnType<typeof setTimeout> | null = null
/** Номер запроса: поздний ответ не должен перетирать свежие варианты. */
let request = 0

function urlFor(key: Variant['key']): string | undefined {
  return variants.value.find(item => item.key === key)?.url ?? undefined
}

/**
 * Варианты пересобираются с задержкой: три `crop()` подряд рисуют холст и
 * кодируют файл, и делать это на каждый пиксель перетаскивания значит греть
 * процессор ради кадров, которых никто не увидит.
 */
function scheduleVariants() {
  if (pending)
    clearTimeout(pending)

  pending = setTimeout(async () => {
    const current = ++request

    for (const variant of variants.value) {
      // Размер задаётся пропом — тем же способом, что и в приложении.
      outputWidth.value = variant.px
      await nextTick()

      const blob = await cropper.value?.crop()
      if (!blob || current !== request)
        return

      if (variant.url)
        URL.revokeObjectURL(variant.url)

      variant.url = URL.createObjectURL(blob)
      variant.weight = blob.size
    }
  }, 300)
}

onBeforeUnmount(() => {
  if (pending)
    clearTimeout(pending)

  for (const variant of variants.value) {
    if (variant.url)
      URL.revokeObjectURL(variant.url)
  }
})
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-[minmax(0,300px)_minmax(0,1fr)]">
    <div class="grid gap-3">
      <GrImageCrop
        ref="cropper"
        :src="source"
        shape="circle"
        :aspect-ratio="1"
        :output="{ width: outputWidth }"
        @change="scheduleVariants"
        @load="scheduleVariants"
      />
      <p class="showcase-demo-text text-sm">
        Тяните картинку и меняйте увеличение — три файла справа пересобираются следом.
      </p>
    </div>

    <div class="grid content-start gap-4">
      <GrCard padding="md">
        <div class="flex flex-wrap items-end gap-4">
          <div v-for="variant in variants" :key="variant.key" class="grid justify-items-center gap-1">
            <img
              v-if="variant.url"
              :src="variant.url"
              :alt="`${variant.label} вариант`"
              class="rounded-[var(--gr-radius-full)] object-cover"
              :style="{ width: `${Math.min(variant.px, 96)}px`, height: `${Math.min(variant.px, 96)}px` }"
            >
            <span class="showcase-demo-text text-xs">
              {{ variant.label }} · {{ variant.px }} px
            </span>
            <span class="showcase-demo-text text-xs">
              {{ (variant.weight / 1024).toFixed(1) }} КБ
            </span>
          </div>
        </div>
      </GrCard>

      <GrCard padding="md">
        <div class="flex items-center gap-3">
          <GrAvatar :src="urlFor('medium')" size="lg" />
          <div class="grid">
            <span class="text-[length:var(--gr-text-sm)] leading-[var(--gr-leading-sm)] font-600">Иван Петров</span>
            <span class="showcase-demo-text text-xs">Шапка профиля — сюда идёт средний</span>
          </div>
        </div>
      </GrCard>

      <GrCard padding="md">
        <div class="grid gap-2">
          <div v-for="row in ['Отчёт за август', 'Договор №14', 'Заявка на отпуск']" :key="row" class="flex items-center gap-2">
            <GrAvatar :src="urlFor('small')" size="xs" />
            <span class="showcase-demo-text text-sm">{{ row }}</span>
          </div>
          <span class="showcase-demo-text text-xs">
            Строка списка — мелкий: 256 px здесь означал бы лишние килобайты на каждой строке
          </span>
        </div>
      </GrCard>
    </div>
  </div>
</template>

Basic

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

import { GrButton, GrRadioGroup } from '@feugene/granularity'

/**
 * Картинка синтезированная: демо обязано работать без сети и без файла на
 * диске, а кадрировать нужно что-то заведомо не квадратное — иначе не видно,
 * что именно выбирает пользователь.
 */
const source = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
  <svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
    <rect width="1600" height="900" fill="#0f172a" />
    <circle cx="1180" cy="300" r="220" fill="#38bdf8" fill-opacity="0.5" />
    <circle cx="480" cy="620" r="160" fill="#f472b6" fill-opacity="0.55" />
    <rect x="120" y="140" width="520" height="26" rx="13" fill="white" fill-opacity="0.7" />
    <rect x="120" y="196" width="360" height="20" rx="10" fill="white" fill-opacity="0.4" />
    <text x="120" y="470" fill="white" font-size="128" font-family="Arial, sans-serif" font-weight="700">1600 × 900</text>
  </svg>
`)}`

const shape = ref<'circle' | 'rect'>('circle')
const zoom = ref(1)
const result = ref<string | null>(null)
const resultSize = ref(0)

const cropper = useTemplateRef('cropper')

const shapeOptions = [
  { value: 'circle', label: 'Круг' },
  { value: 'rect', label: 'Прямоугольник' },
] satisfies Array<{ value: 'circle' | 'rect', label: string }>

const weight = computed(() => `${(resultSize.value / 1024).toFixed(1)} КБ`)

async function takeFrame() {
  const blob = await cropper.value?.crop()
  if (!blob)
    return

  if (result.value)
    URL.revokeObjectURL(result.value)

  result.value = URL.createObjectURL(blob)
  resultSize.value = blob.size
}
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
    <div class="grid gap-3">
      <GrImageCrop
        ref="cropper"
        v-model:zoom="zoom"
        :src="source"
        :shape="shape"
        :aspect-ratio="1"
        :output="{ width: 256, height: 256, type: 'image/png' }"
      />

      <GrRadioGroup v-model="shape" :options="shapeOptions" variant="button" size="sm" />
      <GrButton size="sm" @click="takeFrame">
        Вырезать кадр
      </GrButton>
    </div>

    <div class="showcase-demo-panel grid content-start gap-3 rounded-[var(--gr-radius-lg)] border p-4">
      <p class="showcase-demo-text text-sm">
        Рамка неподвижна: пользователь тянет картинку под ней и меняет увеличение.
        Клавиатурой — стрелки и <code>+</code>/<code>-</code>, <code>Home</code> сбрасывает.
      </p>

      <template v-if="result">
        <img :src="result" alt="Вырезанный кадр" class="h-32 w-32 rounded-[var(--gr-radius-full)] object-cover">
        <p class="showcase-demo-text text-sm">
          Результат: 256 × 256, {{ weight }}. Круг — это маска показа, а сам файл прямоугольный.
        </p>
      </template>
      <p v-else class="showcase-demo-text text-sm">
        Нажмите «Вырезать кадр» — здесь появится результат.
      </p>
    </div>
  </div>
</template>

Weight

Weight
<script setup lang="ts">
import { onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'

import { GrSegmented, GrSlider } from '@feugene/granularity'

/**
 * Сколько на самом деле весит результат.
 *
 * `output.type` и `output.quality` описанием пропа не объяснишь: разница между
 * webp и jpeg на одной картинке — это два числа, и увидеть их можно только
 * рядом. Заодно видно, что у png качество не спрашивают вовсе.
 */
const source = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
  <svg xmlns="http://www.w3.org/2000/svg" width="1400" height="1000" viewBox="0 0 1400 1000">
    <defs>
      <radialGradient id="g" cx="35%" cy="30%">
        <stop offset="0%" stop-color="#fde68a" />
        <stop offset="55%" stop-color="#fb7185" />
        <stop offset="100%" stop-color="#1e1b4b" />
      </radialGradient>
    </defs>
    <rect width="1400" height="1000" fill="url(#g)" />
    <circle cx="1050" cy="260" r="180" fill="#22d3ee" fill-opacity="0.55" />
    <circle cx="360" cy="760" r="220" fill="#a78bfa" fill-opacity="0.5" />
    <rect x="120" y="120" width="520" height="26" rx="13" fill="white" fill-opacity="0.75" />
  </svg>
`)}`

const format = ref<'image/webp' | 'image/jpeg' | 'image/png'>('image/webp')
const quality = ref(0.8)
const weight = ref<number | null>(null)
const preview = ref<string | null>(null)
const cropper = useTemplateRef('cropper')

const formatOptions = [
  { value: 'image/webp', label: 'webp' },
  { value: 'image/jpeg', label: 'jpeg' },
  { value: 'image/png', label: 'png' },
] satisfies Array<{ value: 'image/webp' | 'image/jpeg' | 'image/png', label: string }>

let pending: ReturnType<typeof setTimeout> | null = null
/**
 * Номер запроса: кодирование асинхронно, и два вызова в полёте возвращаются в
 * произвольном порядке. Без этого счётчика вес png успевал перезаписаться
 * ответом от jpeg — на экране оставалось число от предыдущего формата.
 */
let request = 0

function scheduleMeasure() {
  if (pending)
    clearTimeout(pending)

  pending = setTimeout(async () => {
    const current = ++request
    const blob = await cropper.value?.crop()
    if (!blob || current !== request)
      return

    weight.value = blob.size

    if (preview.value)
      URL.revokeObjectURL(preview.value)

    preview.value = URL.createObjectURL(blob)
  }, 250)
}

watch([format, quality], scheduleMeasure)

onBeforeUnmount(() => {
  if (pending)
    clearTimeout(pending)
  if (preview.value)
    URL.revokeObjectURL(preview.value)
})
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-2">
    <div class="grid gap-3">
      <GrImageCrop
        ref="cropper"
        :src="source"
        :aspect-ratio="4 / 3"
        :output="{ width: 1024, type: format, quality }"
        @change="scheduleMeasure"
        @load="scheduleMeasure"
      />
      <GrSegmented v-model="format" :options="formatOptions" size="sm" />

      <div class="grid gap-1">
        <label class="showcase-demo-text text-sm">
          Качество: {{ format === 'image/png' ? 'не применяется' : quality.toFixed(2) }}
        </label>
        <GrSlider
          v-model="quality"
          :min="0.3"
          :max="1"
          :step="0.05"
          size="sm"
          :disabled="format === 'image/png'"
          aria-label="Качество кодирования"
        />
      </div>
    </div>

    <div class="showcase-demo-panel grid content-start gap-3 rounded-[var(--gr-radius-lg)] border p-4">
      <p v-if="weight" class="text-[length:var(--gr-text-lg)] leading-[var(--gr-leading-base)] font-600">
        {{ (weight / 1024).toFixed(1) }} КБ
      </p>
      <p class="showcase-demo-text text-sm">
        Кадр 1024 px по ширине. Один и тот же кадр в webp обычно вдвое легче jpeg того же
        качества, а png не сжимает с потерями вовсе — <code>quality</code> он игнорирует,
        поэтому ползунок для него выключен.
      </p>

      <img v-if="preview" :src="preview" alt="Результат кадрирования" class="w-full rounded-[var(--gr-radius-md)]">

      <p class="showcase-demo-text text-sm">
        Это и есть ответ на вопрос, что ставить в <code>output</code>: для фотографий — webp
        с качеством около 0.8, для скриншотов с текстом — png.
      </p>
    </div>
  </div>
</template>

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