Admin 元件
Cropper
圖片裁切。可拖移 selection、zoom、即時預覽;可搭配 dialog/stepper 做多尺寸裁切。
安裝
Terminal
pnpm dlx shadcn-vue add @admin/cropperimport 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(必填) |
aspectRatio | number | undefined | — | 裁切框長寬比;不指定則自由比例 |
minZoom | number | undefined | 0.1 | 縮放下限 |
maxZoom | number | undefined | 3 | 縮放上限 |
initialZoom | number | undefined | 1 | 載入後初始縮放倍率 |
zoomable | boolean | undefined | true | 設為 `false` 時隱藏縮放控制 |
gifMaxColors | number | undefined | 256 | GIF 來源編碼時最大色票數 |
gifFrameStride | number | undefined | 1 | GIF 來源編碼時跳幀間隔 |
selectionWithin | AdminCropperSelectionWithin | undefined | "none" | 限制裁切框可移動範圍 |
selectionRedrawable | boolean | undefined | true | 設為 `false` 時禁用重新繪製選取框互動 |
minSelectionSize | AdminCropperMinSelectionSize | undefined | — | 限制裁切框最小尺寸(避免過小);觸發時 emit `sourceTooSmall` |
class | ClassValue | — | 根元素之自訂 class |
result | CropResult | undefined | — | — |
* 代表必填
Events
| 名稱 | Payload | 說明 |
|---|---|---|
update:result | CropResult | undefined | — |
sourceTooSmall | AdminCropperSourceTooSmallPayload | 來源圖過小無法滿足 `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 | — | — |
minZoom | number | undefined | 0.1 | — |
maxZoom | number | undefined | 3 | — |
step | number | undefined | 0.05 | — |
class | ClassValue | — | — |
* 代表必填
Events
| 名稱 | Payload | 說明 |
|---|---|---|
zoomTo | number | 透過 slider 設定絕對縮放值 |
zoomBy | number | 透過按鈕加減縮放 |
admin-cropper-preview
Props
| 名稱 | 型別 | 預設值 | 說明 |
|---|---|---|---|
selection | string | undefined | — | 若省略且位於 `<AdminCropper>` 內,則使用 context 的 `selectionId` |
class | ClassValue | — | — |
注意事項
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會 throwSecurityError。元件已加crossorigin="anonymous",picsum 等 CDN 可正常運作。 - GIF 輸出另需 fetch CORS:GIF pipeline 會額外
fetch(src)重新取 bytes,server 同樣要回Access-Control-Allow-Origin;<img>顯示成功不代表 fetch 拿得到。