Admin 元件

Cropper

參考
圖片裁切。可拖移 selection、zoom、即時預覽;可搭配 dialog/stepper 做多尺寸裁切。

安裝

Terminal
pnpm dlx shadcn-vue add @admin/cropper
import type { CropResult } from '@/components/admin/cropper'import {  AdminCropper,  AdminCropperPreview,  AdminCropperZoom,  useAdminCropperContext} from '@/components/admin/cropper'

cropperjs v2 是一組 web components(<cropper-canvas> / <cropper-image> / <cropper-selection> …),需要告訴 Vue 編譯器把它們當成 native custom element,否則 dev console 會出現 [Vue warn]: Failed to resolve component: cropper-* 警告。視專案類型把下列設定加進去:

Nuxt 3 / 4

nuxt.config.ts
export default defineNuxtConfig({  vue: {    compilerOptions: {      isCustomElement: (tag) => tag.startsWith('cropper-')    }  }})

Vue 3 + Vite

vite.config.ts
import vue from '@vitejs/plugin-vue'import { defineConfig } from 'vite'​export default defineConfig({  plugins: [    vue({      template: {        compilerOptions: {          isCustomElement: (tag) => tag.startsWith('cropper-')        }      }    })  ]})

用法

<script setup lang="ts">import type { CropResult } from '@/components/admin/cropper'import { ref } from 'vue'import { cn } from '@/lib/utils'import { AdminCropper, AdminCropperPreview, AdminCropperZoom } from '@/components/admin/cropper'​const src = ref('https://picsum.photos/id/1018/1200/800')const result = ref<CropResult>()​function randomize() {  const id = Math.floor(Math.random() * 1000)  src.value = `https://picsum.photos/id/${id}/1200/800`}</script>​<template>  <div class="flex flex-col gap-4">    <div class="flex items-center gap-2">      <input        v-model="src"        placeholder="圖片 URL"        type="url"        :class="          cn(`            h-9 flex-1 rounded-md border border-text-30 bg-bg-90 px-2 typo-content-medium            text-text-70          `)        "      />      <button        type="button"        :class="          cn(`            h-9 cursor-pointer rounded-md border border-primary-50 bg-bg-90 px-3 typo-content-medium            text-primary-50          `)        "        @click="randomize"      >        隨機 picsum      </button>    </div>​    <client-only>      <admin-cropper v-model:result="result" :src="src">        <template #default="{ selectionId, zoom, zoomTo, zoomBy, minZoom, maxZoom, zoomable }">          <admin-cropper-preview class="size-30" :selection="selectionId" />          <admin-cropper-zoom            v-if="zoomable"            :max-zoom="maxZoom"            :min-zoom="minZoom"            :zoom="zoom"            @zoom-by="zoomBy"            @zoom-to="zoomTo"          />        </template>      </admin-cropper>    </client-only>  </div></template>

場景

互動 playground

<script setup lang="ts">import type {  AdminCropperMinSelectionSize,  AdminCropperSelectionWithin,  AdminCropperSourceTooSmallPayload,  CropResult} from '@/components/admin/cropper'import { computed, ref, watch } from 'vue'import { cn } from '@/lib/utils'import { AdminButton } from '@/components/admin/button'import { AdminCropper, AdminCropperPreview, AdminCropperZoom } from '@/components/admin/cropper'​type Mime = 'image/png' | 'image/jpeg' | 'image/webp'type Method = 'toCanvas' | 'toDataURL' | 'toBlob'type MinPreset = 'disabled' | 'preset' | 'custom'​const MIME_OPTIONS: readonly Mime[] = ['image/png', 'image/jpeg', 'image/webp']const METHOD_OPTIONS: readonly Method[] = ['toCanvas', 'toDataURL', 'toBlob']const MIN_PRESET_OPTIONS: readonly { value: MinPreset; label: string }[] = [  { value: 'disabled', label: '關閉' },  { value: 'preset', label: '1200×630' },  { value: 'custom', label: '自訂' }]const ASPECT_OPTIONS: readonly { label: string; value: number | undefined }[] = [  { label: '自由', value: undefined },  { label: '16:9', value: 16 / 9 },  { label: '4:3', value: 4 / 3 },  { label: '1:1', value: 1 },  { label: '3:4', value: 3 / 4 }]const WITHIN_OPTIONS: readonly AdminCropperSelectionWithin[] = ['none', 'canvas', 'image']​const src = ref('https://picsum.photos/id/1018/1200/800')const result = ref<CropResult>()​const aspectRatio = ref<number | undefined>(undefined)const selectionWithin = ref<AdminCropperSelectionWithin>('none')const selectionRedrawable = ref(true)​const minPreset = ref<MinPreset>('disabled')const customMinWidth = ref(800)const customMinHeight = ref(800)​const mime = ref<Mime>('image/png')const method = ref<Method>('toBlob')const width = ref(600)const height = ref(400)const quality = ref(0.92)​const minSelectionSize = computed<AdminCropperMinSelectionSize | undefined>(() => {  if (minPreset.value === 'disabled') {    return undefined  }  if (minPreset.value === 'preset') {    return { width: 1200, height: 630 }  }  return { width: customMinWidth.value, height: customMinHeight.value }})​const tooSmall = ref<AdminCropperSourceTooSmallPayload>()const upscaleWarn = ref(false)​function onSourceTooSmall(payload: AdminCropperSourceTooSmallPayload) {  tooSmall.value = payload}​watch(src, () => {  tooSmall.value = undefined})watch(minSelectionSize, () => {  tooSmall.value = undefined})​const previewUrl = ref<string>()const summary = ref<string>('尚未匯出')const actualSize = ref<{ width: number; height: number }>()​const qualityApplies = computed(() => mime.value !== 'image/png')const sizeMismatch = computed(() => {  if (!actualSize.value) {    return false  }  return actualSize.value.width !== width.value || actualSize.value.height !== height.value})​function onPreviewLoad(event: Event) {  const img = event.target as HTMLImageElement  actualSize.value = { width: img.naturalWidth, height: img.naturalHeight }}​function revokePreview() {  if (previewUrl.value?.startsWith('blob:')) {    URL.revokeObjectURL(previewUrl.value)  }  previewUrl.value = undefined}​async function exportImage() {  if (!result.value) {    return  }  revokePreview()  actualSize.value = undefined​  const size = { width: width.value, height: height.value }​  if (method.value === 'toCanvas') {    const canvas = await result.value.toCanvas(size)    previewUrl.value = canvas.toDataURL(mime.value, quality.value)    summary.value = `toCanvas → ${canvas.width}×${canvas.height} HTMLCanvasElement`  } else if (method.value === 'toDataURL') {    const dataUrl = await result.value.toDataURL({      ...size,      mime: mime.value,      quality: quality.value    })    previewUrl.value = dataUrl    summary.value = `toDataURL → ${mime.value}, ${(dataUrl.length / 1024).toFixed(1)} KB(base64 字串)`  } else {    const blob = await result.value.toBlob({ ...size, mime: mime.value, quality: quality.value })    previewUrl.value = URL.createObjectURL(blob)    summary.value = `toBlob → ${blob.type}, ${(blob.size / 1024).toFixed(1)} KB`  }​  upscaleWarn.value = result.value.upscale}​function randomize() {  const id = Math.floor(Math.random() * 1000)  src.value = `https://picsum.photos/id/${id}/1200/800`}</script>​<template>  <div class="flex flex-col gap-4">    <div      :class="        cn(`          flex flex-col gap-2          sm:flex-row sm:flex-wrap sm:items-center        `)      "    >      <span class="shrink-0 typo-content-medium text-text-70">aspect-ratio</span>      <div class="flex flex-wrap gap-1.5">        <button          v-for="opt in ASPECT_OPTIONS"          :key="opt.label"          type="button"          :class="            cn(              'cursor-pointer rounded-md border px-3 py-1.5 typo-content-medium transition-colors',              aspectRatio === opt.value                ? 'border-primary-50 bg-primary-10 text-primary-50'                : `                  border-text-30 bg-bg-90 text-text-70                  hover:border-text-40                `            )          "          @click="aspectRatio = opt.value"        >          {{ opt.label }}        </button>      </div>    </div>​    <div      :class="        cn(`          flex flex-col gap-2          sm:flex-row sm:flex-wrap sm:items-center        `)      "    >      <span class="shrink-0 typo-content-medium text-text-70">selection-within</span>      <div class="flex flex-wrap gap-1.5">        <button          v-for="opt in WITHIN_OPTIONS"          :key="opt"          type="button"          :class="            cn(              `                cursor-pointer rounded-md border px-3 py-1.5 typo-content-medium capitalize                transition-colors              `,              selectionWithin === opt                ? 'border-primary-50 bg-primary-10 text-primary-50'                : `                  border-text-30 bg-bg-90 text-text-70                  hover:border-text-40                `            )          "          @click="selectionWithin = opt"        >          {{ opt }}        </button>      </div>      <label class="flex items-center gap-1.5 typo-content-medium text-text-70">        <input v-model="selectionRedrawable" type="checkbox" />        允許暗區重選 (selection-redrawable)      </label>    </div>​    <div      :class="        cn(`          flex flex-col gap-2          sm:flex-row sm:flex-wrap sm:items-center        `)      "    >      <span class="shrink-0 typo-content-medium text-text-70">min-selection-size</span>      <div class="flex flex-wrap gap-1.5">        <button          v-for="opt in MIN_PRESET_OPTIONS"          :key="opt.value"          type="button"          :class="            cn(              'cursor-pointer rounded-md border px-3 py-1.5 typo-content-medium transition-colors',              minPreset === opt.value                ? 'border-primary-50 bg-primary-10 text-primary-50'                : `                  border-text-30 bg-bg-90 text-text-70                  hover:border-text-40                `            )          "          @click="minPreset = opt.value"        >          {{ opt.label }}        </button>      </div>      <template v-if="minPreset === 'custom'">        <label class="flex items-center gap-1.5 typo-content-medium text-text-70">          min 寬          <input            v-model.number="customMinWidth"            min="1"            type="number"            :class="              cn(`                h-9 w-24 rounded-md border border-text-30 bg-bg-90 px-2 typo-content-medium                text-text-90              `)            "          />        </label>        <label class="flex items-center gap-1.5 typo-content-medium text-text-70">          min 高          <input            v-model.number="customMinHeight"            min="1"            type="number"            :class="              cn(`                h-9 w-24 rounded-md border border-text-30 bg-bg-90 px-2 typo-content-medium                text-text-90              `)            "          />        </label>      </template>    </div>​    <div      :class="        cn(`          flex flex-col gap-2          sm:flex-row sm:flex-wrap sm:items-center        `)      "    >      <span class="shrink-0 typo-content-medium text-text-70">輸出方式</span>      <div class="flex flex-wrap gap-1.5">        <button          v-for="opt in METHOD_OPTIONS"          :key="opt"          type="button"          :class="            cn(              'cursor-pointer rounded-md border px-3 py-1.5 typo-content-medium transition-colors',              method === opt                ? 'border-primary-50 bg-primary-10 text-primary-50'                : `                  border-text-30 bg-bg-90 text-text-70                  hover:border-text-40                `            )          "          @click="method = opt"        >          {{ opt }}        </button>      </div>      <span class="shrink-0 typo-content-medium text-text-70">格式</span>      <div class="flex flex-wrap gap-1.5">        <button          v-for="opt in MIME_OPTIONS"          :key="opt"          type="button"          :class="            cn(              'cursor-pointer rounded-md border px-3 py-1.5 typo-content-medium transition-colors',              mime === opt                ? 'border-primary-50 bg-primary-10 text-primary-50'                : `                  border-text-30 bg-bg-90 text-text-70                  hover:border-text-40                `            )          "          @click="mime = opt"        >          {{ opt }}        </button>      </div>    </div>​    <div      :class="        cn(`          flex flex-col gap-2          sm:flex-row sm:flex-wrap sm:items-center        `)      "    >      <span class="shrink-0 typo-content-medium text-text-70">輸出尺寸</span>      <label class="flex items-center gap-1.5 typo-content-medium text-text-70">        寬        <input          v-model.number="width"          min="1"          type="number"          :class="            cn(`              h-9 w-24 rounded-md border border-text-30 bg-bg-90 px-2 typo-content-medium              text-text-90            `)          "        />      </label>      <label class="flex items-center gap-1.5 typo-content-medium text-text-70">        高        <input          v-model.number="height"          min="1"          type="number"          :class="            cn(`              h-9 w-24 rounded-md border border-text-30 bg-bg-90 px-2 typo-content-medium              text-text-90            `)          "        />      </label>      <label        :class="          cn(            'flex items-center gap-1.5 typo-content-medium text-text-70',            !qualityApplies && 'opacity-50'          )        "      >        quality        <input          v-model.number="quality"          max="1"          min="0"          step="0.05"          type="number"          :class="            cn(`              h-9 w-24 rounded-md border border-text-30 bg-bg-90 px-2 typo-content-medium              text-text-90              disabled:cursor-not-allowed            `)          "          :disabled="!qualityApplies"        />      </label>      <span v-if="!qualityApplies" class="typo-caption-medium text-text-60">        PNG 為無損格式,瀏覽器會忽略 quality;改用 JPEG / WebP 才會生效      </span>    </div>​    <div class="flex items-center gap-2">      <input        v-model="src"        placeholder="圖片 URL"        type="url"        :class="          cn(`            h-9 flex-1 rounded-md border border-text-30 bg-bg-90 px-2 typo-content-medium            text-text-70          `)        "      />      <button        type="button"        :class="          cn(`            h-9 shrink-0 cursor-pointer rounded-md border border-primary-50 bg-bg-90 px-3            typo-content-medium text-primary-50          `)        "        @click="randomize"      >        隨機 picsum      </button>    </div>​    <client-only>      <admin-cropper        v-model:result="result"        :aspect-ratio="aspectRatio"        :min-selection-size="minSelectionSize"        :selection-redrawable="selectionRedrawable"        :selection-within="selectionWithin"        :src="src"        @source-too-small="onSourceTooSmall"      >        <template #default="{ selectionId, zoom, zoomTo, zoomBy, minZoom, maxZoom, zoomable }">          <admin-cropper-preview class="size-30" :selection="selectionId" />          <admin-cropper-zoom            v-if="zoomable"            :max-zoom="maxZoom"            :min-zoom="minZoom"            :zoom="zoom"            @zoom-by="zoomBy"            @zoom-to="zoomTo"          />        </template>      </admin-cropper>    </client-only>​    <p v-if="tooSmall" class="typo-caption-medium text-warning-50">      原圖 {{ tooSmall.source.width }}×{{ tooSmall.source.height }} 小於要求      {{ tooSmall.min.width }}×{{ tooSmall.min.height }},請改用較大原圖    </p>​    <div class="flex flex-wrap items-center gap-3">      <admin-button @click="exportImage">匯出</admin-button>      <span class="typo-content-medium text-text-70">{{ summary }}</span>      <span        v-if="upscaleWarn"        :class="          cn(`            rounded-md border border-warning-50 bg-warning-10 px-2 py-1 typo-caption-medium            text-warning-50          `)        "      >        目前匯出尺寸大於裁切區域,會被放大      </span>    </div>​    <div v-if="previewUrl" class="flex flex-col gap-2">      <span class="typo-content-medium text-text-70">        輸出預覽        <template v-if="actualSize">          —— 實際輸出 {{ actualSize.width }}×{{ actualSize.height }}        </template>      </span>      <span v-if="sizeMismatch" class="typo-caption-medium text-warning-50">        請求 {{ width }}×{{ height }} 與 selection 比例不一致,cropperjs 以 selection 比例做 contain        縮放,不會拉伸或二次裁切      </span>      <img        alt="匯出結果"        class="max-w-full self-center rounded-md border border-text-20 bg-bg-80"        :src="previewUrl"        :style="{ maxHeight: `${height}px` }"        @load="onPreviewLoad"      />    </div>  </div></template>

自訂 zoom 控制

<script setup lang="ts">import type { CropResult } from '@/components/admin/cropper'import { IconMinus, IconPlus } from '@tabler/icons-vue'import { ref } from 'vue'import { cn } from '@/lib/utils'import { AdminCropper, AdminCropperPreview } from '@/components/admin/cropper'​const src = ref('https://picsum.photos/id/1043/1200/800')const result = ref<CropResult>()</script>​<template>  <client-only>    <admin-cropper v-model:result="result" :src="src">      <template #default="{ zoom, zoomBy, selectionId }">        <admin-cropper-preview class="size-30" :selection="selectionId" />        <div class="flex items-center gap-3 typo-content-medium text-text-70">          <button            type="button"            :class="              cn(`                flex size-8 cursor-pointer items-center justify-center rounded-full border                border-primary-50 text-primary-50 transition-colors                hover:bg-primary-50 hover:text-text-10              `)            "            @click="zoomBy(-0.1)"          >            <icon-minus class="size-4" />          </button>          <span class="w-12 text-center font-mono">{{ Math.round(zoom * 100) }}%</span>          <button            type="button"            :class="              cn(`                flex size-8 cursor-pointer items-center justify-center rounded-full border                border-primary-50 text-primary-50 transition-colors                hover:bg-primary-50 hover:text-text-10              `)            "            @click="zoomBy(0.1)"          >            <icon-plus class="size-4" />          </button>        </div>      </template>    </admin-cropper>  </client-only></template>

多步驟裁切流程

搭配 admin-dialog 與 admin-stepper 完成「電腦版裁切 → 手機版裁切 → 預覽結果」三步驟流程;每一步用一個 <AdminCropper> instance 鎖定不同 aspect ratio,最後一步直接以 result.toDataURL() 顯示成品。

<script setup lang="ts">import type { CropResult } from '@/components/admin/cropper'import { computed, ref, watch } from 'vue'import { cn } from '@/lib/utils'import { AdminButton } from '@/components/admin/button'import { AdminCropper, AdminCropperPreview, AdminCropperZoom } from '@/components/admin/cropper'import {  AdminDialog,  AdminDialogContent,  AdminDialogDescription,  AdminDialogFooter,  AdminDialogTitle,  AdminDialogTrigger} from '@/components/admin/dialog'import {  AdminStepper,  AdminStepperIndicator,  AdminStepperItem,  AdminStepperSeparator,  AdminStepperTitle,  AdminStepperTrigger} from '@/components/admin/stepper'​const open = ref(false)const step = ref<number | undefined>(1)const src = ref('https://picsum.photos/id/1019/1200/800')​const pcResult = ref<CropResult>()const mobileResult = ref<CropResult>()const pcPreview = ref<string>()const mobilePreview = ref<string>()​const steps = [  { value: 1, title: '電腦版素材裁切' },  { value: 2, title: '手機版素材裁切' },  { value: 3, title: '預覽裁切結果' }]​function randomize() {  const id = Math.floor(Math.random() * 1000)  src.value = `https://picsum.photos/id/${id}/1200/800`}​function reset() {  step.value = 1  pcResult.value = undefined  mobileResult.value = undefined  pcPreview.value = undefined  mobilePreview.value = undefined}​async function exportPreview(result: CropResult | undefined) {  if (!result) {    return undefined  }  if (result.sourceFormat === 'gif') {    return result.toDataURL({ mime: 'image/gif' })  }  return result.toDataURL({ mime: 'image/png' })}​async function next() {  if (step.value === 1) {    pcPreview.value = await exportPreview(pcResult.value)    step.value = 2    return  }  if (step.value === 2) {    mobilePreview.value = await exportPreview(mobileResult.value)    step.value = 3    return  }  open.value = false}​function back() {  if (step.value === 1) {    open.value = false    return  }  if (step.value === 2) {    step.value = 1    return  }  step.value = 2}​const primaryLabel = computed(() => (step.value === 3 ? '確定裁切' : '下一步'))const secondaryLabel = computed(() => (step.value === 1 ? '取消' : '上一步'))​watch(open, (val) => {  if (val) {    reset()  }})</script>​<template>  <admin-dialog v-model:open="open">    <admin-dialog-trigger as-child>      <admin-button>編輯素材</admin-button>    </admin-dialog-trigger>    <admin-dialog-content>      <admin-dialog-title class="sr-only">編輯素材</admin-dialog-title>      <admin-dialog-description class="sr-only">        分三步驟裁切電腦版與手機版素材,最後預覽兩個尺寸的結果。      </admin-dialog-description>      <div class="flex flex-col">        <div class="flex justify-center px-5 py-6">          <admin-stepper v-model="step">            <admin-stepper-item v-for="s in steps" :key="s.value" :step="s.value">              <admin-stepper-trigger>                <admin-stepper-indicator>{{ s.value }}</admin-stepper-indicator>                <admin-stepper-title>{{ s.title }}</admin-stepper-title>              </admin-stepper-trigger>              <admin-stepper-separator v-if="s.value !== steps.length" />            </admin-stepper-item>          </admin-stepper>        </div>​        <div class="flex items-center gap-2 px-5 pb-3">          <input            v-model="src"            placeholder="圖片 URL"            type="url"            :class="              cn(`                h-9 flex-1 rounded-md border border-text-30 bg-bg-90 px-2 typo-content-medium                text-text-70              `)            "          />          <button            type="button"            :class="              cn(`                h-9 cursor-pointer rounded-md border border-primary-50 bg-bg-90 px-3                typo-content-medium text-primary-50              `)            "            @click="randomize"          >            隨機 picsum          </button>        </div>​        <client-only>          <admin-cropper            v-if="step === 1"            v-model:result="pcResult"            class="gap-0"            :aspect-ratio="5 / 3"            :src="src"          >            <template #default="{ selectionId, zoom, zoomTo, zoomBy, minZoom, maxZoom, zoomable }">              <admin-cropper-preview                class="absolute top-1/2 right-6 h-30 w-22 -translate-y-1/2"                :selection="selectionId"              />              <admin-cropper-zoom                v-if="zoomable"                :max-zoom="maxZoom"                :min-zoom="minZoom"                :zoom="zoom"                @zoom-by="zoomBy"                @zoom-to="zoomTo"              />            </template>          </admin-cropper>​          <admin-cropper            v-else-if="step === 2"            v-model:result="mobileResult"            class="gap-0"            :aspect-ratio="3 / 4"            :src="src"          >            <template #default="{ selectionId, zoom, zoomTo, zoomBy, minZoom, maxZoom, zoomable }">              <admin-cropper-preview                class="absolute top-1/2 right-6 h-22 w-37 -translate-y-1/2"                :selection="selectionId"              />              <admin-cropper-zoom                v-if="zoomable"                :max-zoom="maxZoom"                :min-zoom="minZoom"                :zoom="zoom"                @zoom-by="zoomBy"                @zoom-to="zoomTo"              />            </template>          </admin-cropper>        </client-only>​        <div v-if="step === 3" class="flex flex-1 items-center justify-center gap-8 bg-bg-80 py-12">          <div class="flex flex-col items-center gap-2">            <img              v-if="pcPreview"              alt="電腦版預覽"              class="h-46 w-77 object-cover"              :src="pcPreview"            />            <p class="typo-title-small text-text-90">電腦版</p>          </div>          <div class="flex flex-col items-center gap-2">            <img              v-if="mobilePreview"              alt="手機版預覽"              class="h-45 w-34 object-cover"              :src="mobilePreview"            />            <p class="typo-title-small text-text-90">手機版</p>          </div>        </div>​        <admin-dialog-footer class="justify-center pt-2 pb-5">          <admin-button variant="outline" @click="back">{{ secondaryLabel }}</admin-button>          <admin-button @click="next">{{ primaryLabel }}</admin-button>        </admin-dialog-footer>      </div>    </admin-dialog-content>  </admin-dialog></template>

裁切 GIF 動畫

呼叫 result.toBlob('image/gif') 或 result.toDataURL('image/gif') 會走 GIF pipeline,重新解碼所有 frame、依 selection 對每幀做像素裁切、再以 gifenc 重新編碼成動畫 GIF。原檔的 frame delay 與動畫長度會被保留。透過 gif-max-colors 與 gif-frame-stride 兩個 prop 可在畫質與檔案大小之間取捨。

<script setup lang="ts">import type { CropResult } from '@/components/admin/cropper'import { computed, ref, watch } from 'vue'import { cn } from '@/lib/utils'import { AdminCropper, AdminCropperPreview, AdminCropperZoom } from '@/components/admin/cropper'​const src = ref('/demo/sample.gif')const result = ref<CropResult>()const maxColors = ref(128)const frameStride = ref(1)const output = ref<string>('')const error = ref<string>('')const busy = ref(false)let lastBlobUrl = ''​const sourceFormatLabel = computed(() =>  result.value?.sourceFormat === 'gif' ? 'GIF(會輸出動畫)' : '靜態圖片')​watch(src, () => {  output.value = ''  error.value = ''})​async function exportGif() {  if (!result.value) {    return  }  error.value = ''  busy.value = true  try {    const blob = await result.value.toBlob({ mime: 'image/gif' })    if (lastBlobUrl) {      URL.revokeObjectURL(lastBlobUrl)    }    lastBlobUrl = URL.createObjectURL(blob)    output.value = lastBlobUrl  } catch (e) {    error.value = e instanceof Error ? e.message : String(e)    output.value = ''  } finally {    busy.value = false  }}​function onFile(event: Event) {  const file = (event.target as HTMLInputElement).files?.[0]  if (!file) {    return  }  if (src.value.startsWith('blob:')) {    URL.revokeObjectURL(src.value)  }  src.value = URL.createObjectURL(file)}</script>​<template>  <div class="flex flex-col gap-4">    <div class="flex flex-wrap items-center gap-2">      <input        accept="image/gif"        type="file"        :class="          cn(`            h-9 flex-1 rounded-md border border-text-30 bg-bg-90 px-2 typo-content-medium            text-text-70            file:mr-2 file:rounded-sm file:border-0 file:bg-primary-50 file:px-3 file:py-1            file:text-text-10          `)        "        @change="onFile"      />      <button        type="button"        :class="          cn(`            h-9 cursor-pointer rounded-md border border-text-30 bg-bg-90 px-3 typo-content-medium            text-text-70            hover:border-primary-50 hover:text-primary-50          `)        "        @click="src = '/demo/sample.gif'"      >        還原預設 GIF      </button>    </div>​    <div class="flex flex-wrap items-center gap-4 typo-content-medium text-text-70">      <label class="flex items-center gap-2">        色盤大小        <input          v-model.number="maxColors"          class="w-32 cursor-pointer accent-primary-50"          max="256"          min="4"          step="4"          type="range"        />        <span class="w-10 text-right">{{ maxColors }}</span>      </label>      <label class="flex items-center gap-2">        每 N 幀取 1        <input          v-model.number="frameStride"          class="w-32 cursor-pointer accent-primary-50"          max="6"          min="1"          step="1"          type="range"        />        <span class="w-6 text-right">{{ frameStride }}</span>      </label>      <span class="ml-auto rounded-sm bg-bg-80 px-2 py-1 typo-caption-medium">        sourceFormat:{{ sourceFormatLabel }}      </span>    </div>​    <client-only>      <admin-cropper        v-model:result="result"        :gif-frame-stride="frameStride"        :gif-max-colors="maxColors"        :src="src"      >        <template #default="{ selectionId, zoom, zoomTo, zoomBy, minZoom, maxZoom, zoomable }">          <admin-cropper-preview class="size-30" :selection="selectionId" />          <admin-cropper-zoom            v-if="zoomable"            :max-zoom="maxZoom"            :min-zoom="minZoom"            :zoom="zoom"            @zoom-by="zoomBy"            @zoom-to="zoomTo"          />        </template>      </admin-cropper>    </client-only>​    <div class="flex flex-wrap items-start gap-4">      <button        type="button"        :class="          cn(`            h-9 cursor-pointer rounded-md border border-primary-50 bg-primary-50 px-4            typo-content-medium text-text-10            disabled:cursor-not-allowed disabled:opacity-50          `)        "        :disabled="busy || !result"        @click="exportGif"      >        {{ busy ? '產生中…' : '輸出 GIF' }}      </button>      <p v-if="error" class="rounded-sm bg-error-10 px-3 py-2 typo-caption-medium text-error-50">        {{ error }}      </p>      <div v-if="output" class="flex flex-col gap-1">        <span class="typo-caption-medium text-text-50">裁切結果(仍為動畫)</span>        <img alt="cropped GIF result" class="rounded-sm border border-text-30" :src="output" />      </div>    </div>  </div></template>

API 參考

組成

<template>  <admin-cropper v-model:result="result" :aspect-ratio :src>    <template      #default="{ selectionId, zoom, zoomBy, zoomTo, minZoom, maxZoom, zoomable }"    >      <admin-cropper-preview :selection="selectionId" />      <admin-cropper-zoom        v-if="zoomable"        :max-zoom="maxZoom"        :min-zoom="minZoom"        :zoom="zoom"        @zoom-by="zoomBy"        @zoom-to="zoomTo"      />    </template>  </admin-cropper></template>

admin-cropper

Props

名稱型別預設值說明
src*string—待裁切之來源圖檔 URL 或 dataURL(必填)
aspectRationumber | undefined—裁切框長寬比;不指定則自由比例
minZoomnumber | undefined0.1縮放下限
maxZoomnumber | undefined3縮放上限
initialZoomnumber | undefined1載入後初始縮放倍率
zoomableboolean | undefinedtrue設為 `false` 時隱藏縮放控制
gifMaxColorsnumber | undefined256GIF 來源編碼時最大色票數
gifFrameStridenumber | undefined1GIF 來源編碼時跳幀間隔
selectionWithinAdminCropperSelectionWithin | undefined"none"限制裁切框可移動範圍
selectionRedrawableboolean | undefinedtrue設為 `false` 時禁用重新繪製選取框互動
minSelectionSizeAdminCropperMinSelectionSize | undefined—限制裁切框最小尺寸(避免過小);觸發時 emit `sourceTooSmall`
classClassValue—根元素之自訂 class
resultCropResult | undefined——

* 代表必填

Events

名稱Payload說明
update:resultCropResult | undefined—
sourceTooSmallAdminCropperSourceTooSmallPayload來源圖過小無法滿足 `minSelectionSize` 時觸發

Slots

名稱Bindings說明
default{ selectionId: string; zoom: number; zoomTo: (v: number) => void; zoomBy: (d: number) => void; minZoom: number; maxZoom: number; zoomable: boolean; }預設 slot,提供 selection id、縮放控制與當前狀態

Expose

名稱簽名說明
reset() => void重置 selection 到圖片中央、zoom 回到 `initial-zoom`
zoomTo(value: number) => void縮放到絕對值(會被 clamp 到 min/max)
zoomBy(delta: number) => void從目前 zoom 加上 delta
setAspectRatio(ratio: number | null) => void命令式變更比例;`null` 表示自由比例

admin-cropper-zoom

Props

名稱型別預設值說明
zoom*number——
minZoomnumber | undefined0.1—
maxZoomnumber | undefined3—
stepnumber | undefined0.05—
classClassValue——

* 代表必填

Events

名稱Payload說明
zoomTonumber透過 slider 設定絕對縮放值
zoomBynumber透過按鈕加減縮放

admin-cropper-preview

Props

名稱型別預設值說明
selectionstring | undefined—若省略且位於 `<AdminCropper>` 內,則使用 context 的 `selectionId`
classClassValue——

注意事項

Selection 行為

  • selection-within:只限制畫布內的拖動範圍(軸對齊邊界;旋轉時取可視外包矩形),不影響 result.region 仍以原圖像素表達。預設 'none' 允許拖到圖外(適合 GIF 透明邊);要嚴格框在圖內設 'image'。
  • selection-redrawable:true(預設)可在選框外暗區拖曳重畫;false 鎖定為只能調整現有框,避免表單誤觸。
  • min-selection-size 與 aspect-ratio:兩者比例不一致時 dev console 會 warn(不 throw),實際能拖到的最小尺寸會被兩個限制夾住、可能比 min 還大。建議讓 min 寬高直接等於目標輸出尺寸。

輸出行為

  • toCanvas / toDataURL / toBlob 皆為 async,都回傳 Promise。
  • 預設不降採樣:非 GIF 路徑輸出原圖像素座標下的裁切區域,不會因畫面上 selection 較小而縮圖;要刻意縮放請傳 { width, height }。
  • toCanvas() 對 GIF 來源只輸出當前一幀:要保留動畫請改用 toBlob({ mime: 'image/gif' }) 或 toDataURL({ mime: 'image/gif' })。
  • GIF pipeline 跑在 main thread:解碼/量化/編碼皆同步。建議來源 < 2MB / < 50 frames,超過會凍結 UI 100ms 以上,可用 gif-frame-stride 跳幀緩解。
  • 部分 GIF 走 lossy 重量化:僅當來源每一幀皆為 disposal type 2(還原到背景)時才走保留原始色盤的無損裁切路徑;多數含 disposal 0/1 幀的一般 GIF 會改走 composite 後以 gif-max-colors 重新量化的路徑,可能出現輕微色帶。此為畫質取捨,非錯誤。

SSR 與 Vue 設定

  • 不支援 SSR:cropperjs v2 web components 只能在瀏覽器 register;元件殼不含 <ClientOnly>。Nuxt 請自行以 <ClientOnly> 包覆,其他 SSR 框架用等價機制;純 CSR 不需處理。見 安裝 · SSR。
  • 必須設定 isCustomElement:否則 dev console 會印 [Vue warn]: Failed to resolve component: cropper-*。寫法見上方「安裝」章節。

CORS

  • 一般輸出:跨域圖片需回 Access-Control-Allow-Origin,否則 toCanvas / toDataURL / toBlob 會 throw SecurityError。元件已加 crossorigin="anonymous",picsum 等 CDN 可正常運作。
  • GIF 輸出另需 fetch CORS:GIF pipeline 會額外 fetch(src) 重新取 bytes,server 同樣要回 Access-Control-Allow-Origin;<img> 顯示成功不代表 fetch 拿得到。

更新紀錄

  • chore: 導入 vite-doctor 雙 config 並收斂 portable 型別斷言000d326
  • fix: 修正 zoom 同步、CropResult 即時來源與穩定 identity52e8aa3
  • chore: 啟用 vue/no-useless-v-bind 與 vue/prefer-separate-static-class16c34a8