feat(cropper): upgrade cropperjs from v1 to v2

cropperjs v2 is a complete rewrite using Web Components architecture.

- Cropper.vue: rewrite to use v2 API
  - `new Cropper(img, { container })` with Web Components template
  - `selection.$toCanvas()` (async) replaces `cropper.getCroppedCanvas()`
  - Selection `change` event replaces `crop`/`cropmove` callbacks
  - CropperImage `load` event replaces `ready` callback
- CopperModal.vue: update toolbar handlers
  - `cropperImage.$rotate()` replaces `cropper.rotate()`
  - `cropperImage.$zoom()` replaces `cropper.zoom()`
  - `cropperImage.$scale()` replaces `cropper.scaleX/Y()`
  - `cropperImage.$resetTransform()` + `selection.$reset()` replaces `cropper.reset()`
- types.ts: replace `Cropper.Data` with inline type (v2 has no Data type)
- Remove v1 CSS import (v2 uses shadow DOM styles)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pull/878/head
DevDengChao 2026-03-05 13:37:34 +08:00
parent 2520de56b4
commit c4908548a1
5 changed files with 193 additions and 114 deletions

View File

@ -44,7 +44,7 @@
"benz-amr-recorder": "^1.1.5",
"bpmn-js-token-simulation": "^0.39.2",
"camunda-bpmn-moddle": "^7.0.1",
"cropperjs": "^1.6.2",
"cropperjs": "^2.1.0",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.19",
"diagram-js": "^15.9.1",

View File

@ -57,8 +57,8 @@ importers:
specifier: ^7.0.1
version: 7.0.1
cropperjs:
specifier: ^1.6.2
version: 1.6.2
specifier: ^2.1.0
version: 2.1.0
crypto-js:
specifier: ^4.2.0
version: 4.2.0
@ -988,6 +988,39 @@ packages:
resolution: {integrity: sha512-51OWa1Gi6ODOasPmfJPq6js4pZoomima4XLZZCrkldaH2V5Nb3bVhNXPeT6XV0gubbainSpTw4zi68NqAeCNCg==}
engines: {node: '>=v18'}
'@cropper/element-canvas@2.1.0':
resolution: {integrity: sha512-el+rfJpZxsD2q5XxDBA4fRczcrOqB65Lb7roqXOq8LKufwf4bPWA9C6DjNJJahh/TP94dsLIEy3tSkgRMDv3Aw==}
'@cropper/element-crosshair@2.1.0':
resolution: {integrity: sha512-0V589dAx8uZAfvJwdINLn76gfPQEafPH94ukjJ76uX0FCUovLaAVX+VRD/MDSYn0Mza/xejzmL9Dhd1DfemvmA==}
'@cropper/element-grid@2.1.0':
resolution: {integrity: sha512-dEnk0rO+vp553LMvsPYgfrqVFcYXeVFrgFeavBYYEhAXtO40p7kN4rmLYLMMjaN+T/Mx2BATv6kUQpALKy2HLw==}
'@cropper/element-handle@2.1.0':
resolution: {integrity: sha512-8BklWA4C/2GGAULupIWleSnGutECvYt3vx9flodqDfZpDEozws4LgLqmmzVuQmVkRVUdLnXdtx28kjgWLtzkHg==}
'@cropper/element-image@2.1.0':
resolution: {integrity: sha512-mXOV8ixJvG0XtTxLebYAKDjEkFbFOQnsF02hXPZk1yQSV0J+LLhN7a2NePrtKnoTsEV19fhhX3UorMoyGGxvzg==}
'@cropper/element-selection@2.1.0':
resolution: {integrity: sha512-mtFtBl6HIa/s9TWohXw+Z5eJoeYTqylrIcHvS7oVv0uM7IyeRwBW65Q7z+KtLfq/LW+2Sw/XDyvR+VN/DawBPw==}
'@cropper/element-shade@2.1.0':
resolution: {integrity: sha512-zMdyqbb0lc0Vd1oj2Z1miIZvhyZG41OXMHvrNt0hNwblh0dVdrvtw48lnFDgRv+672vt2CNx7Q04GuvCQfPlgg==}
'@cropper/element-viewer@2.1.0':
resolution: {integrity: sha512-XnxlQuqHitd1FOFZ6E0yXAF5NYd/LyIvONLLHI9p1rJw747WYKUPxQaSYtFKF7IOizJu/8mMj++Zc1dZ5ZP3YQ==}
'@cropper/element@2.1.0':
resolution: {integrity: sha512-2zELddqHQNmlvkPoiYzE5nxEjPE+C8nXoTPuvV3FvLp3YjBinc7qb73Icg9UXP0o9qC4+h9q96JgGo0AyMO/Ng==}
'@cropper/elements@2.1.0':
resolution: {integrity: sha512-qvzlYDn3VQgPPpsCu6Gi1XUO0v3vpXQFSjjxcVijbXeNsl/eiKrN7H9/CEiRgi5vr8kXfd7ZvgYxBjUBbH+y+w==}
'@cropper/utils@2.1.0':
resolution: {integrity: sha512-wLtpZ4/UWgo+fGmG8NBWge8x5ehjfDe9ovleDfLy8kpwFaw43XXOEXQtRL1UNr0u4JZxaeO8FcXcolRWUUrlRQ==}
'@csstools/css-calc@3.1.1':
resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==}
engines: {node: '>=20.19.0'}
@ -3046,8 +3079,8 @@ packages:
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cropperjs@1.6.2:
resolution: {integrity: sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==}
cropperjs@2.1.0:
resolution: {integrity: sha512-SsSDqdVRl+mjbIBkGWlk1gCGcc+HzBqCbH5EQ+1tkAFUdxq2KUGukXF1RqhmvXrrdrX7PDwSUkWgXS7E36KvGQ==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
@ -6528,6 +6561,72 @@ snapshots:
conventional-commits-parser: 6.3.0
picocolors: 1.1.1
'@cropper/element-canvas@2.1.0':
dependencies:
'@cropper/element': 2.1.0
'@cropper/utils': 2.1.0
'@cropper/element-crosshair@2.1.0':
dependencies:
'@cropper/element': 2.1.0
'@cropper/utils': 2.1.0
'@cropper/element-grid@2.1.0':
dependencies:
'@cropper/element': 2.1.0
'@cropper/utils': 2.1.0
'@cropper/element-handle@2.1.0':
dependencies:
'@cropper/element': 2.1.0
'@cropper/utils': 2.1.0
'@cropper/element-image@2.1.0':
dependencies:
'@cropper/element': 2.1.0
'@cropper/element-canvas': 2.1.0
'@cropper/utils': 2.1.0
'@cropper/element-selection@2.1.0':
dependencies:
'@cropper/element': 2.1.0
'@cropper/element-canvas': 2.1.0
'@cropper/element-image': 2.1.0
'@cropper/utils': 2.1.0
'@cropper/element-shade@2.1.0':
dependencies:
'@cropper/element': 2.1.0
'@cropper/element-canvas': 2.1.0
'@cropper/element-selection': 2.1.0
'@cropper/utils': 2.1.0
'@cropper/element-viewer@2.1.0':
dependencies:
'@cropper/element': 2.1.0
'@cropper/element-canvas': 2.1.0
'@cropper/element-image': 2.1.0
'@cropper/element-selection': 2.1.0
'@cropper/utils': 2.1.0
'@cropper/element@2.1.0':
dependencies:
'@cropper/utils': 2.1.0
'@cropper/elements@2.1.0':
dependencies:
'@cropper/element': 2.1.0
'@cropper/element-canvas': 2.1.0
'@cropper/element-crosshair': 2.1.0
'@cropper/element-grid': 2.1.0
'@cropper/element-handle': 2.1.0
'@cropper/element-image': 2.1.0
'@cropper/element-selection': 2.1.0
'@cropper/element-shade': 2.1.0
'@cropper/element-viewer': 2.1.0
'@cropper/utils@2.1.0': {}
'@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
@ -8549,7 +8648,10 @@ snapshots:
crelt@1.0.6: {}
cropperjs@1.6.2: {}
cropperjs@2.1.0:
dependencies:
'@cropper/elements': 2.1.0
'@cropper/utils': 2.1.0
cross-spawn@7.0.6:
dependencies:

View File

@ -162,13 +162,24 @@ function handleReady(cropperInstance: Cropper) {
}
function handlerToolbar(event: string, arg?: number) {
if (event === 'scaleX') {
scaleX = arg = scaleX === -1 ? 1 : -1
if (!cropper.value) return
const cropperImage = cropper.value.getCropperImage()
const cropperSelection = cropper.value.getCropperSelection()
if (event === 'reset') {
cropperImage?.$resetTransform()
cropperSelection?.$reset()
} else if (event === 'rotate') {
cropperImage?.$rotate(`${arg}deg`)
} else if (event === 'scaleX') {
scaleX = scaleX === -1 ? 1 : -1
cropperImage?.$scale(scaleX, 1)
} else if (event === 'scaleY') {
scaleY = scaleY === -1 ? 1 : -1
cropperImage?.$scale(1, scaleY)
} else if (event === 'zoom') {
cropperImage?.$zoom(arg!)
}
if (event === 'scaleY') {
scaleY = arg = scaleY === -1 ? 1 : -1
}
cropper?.value?.[event]?.(arg)
}
async function handleOk() {

View File

@ -1,50 +1,23 @@
<template>
<div :class="getClass" :style="getWrapperStyle">
<div ref="containerRef" :class="getClass" :style="getWrapperStyle">
<img
v-show="isReady"
v-show="false"
ref="imgElRef"
:alt="alt"
:crossorigin="crossorigin"
:src="src"
:style="getImageStyle"
/>
</div>
</template>
<script lang="ts" setup>
import { CSSProperties, PropType } from 'vue'
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
import { useDesign } from '@/hooks/web/useDesign'
import { propTypes } from '@/utils/propTypes'
import { useDebounceFn } from '@vueuse/core'
defineOptions({ name: 'Cropper' })
type Options = Cropper.Options
const defaultOptions: Options = {
aspectRatio: 1,
zoomable: true,
zoomOnTouch: true,
zoomOnWheel: true,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: true,
autoCrop: true,
background: true,
highlight: true,
center: true,
responsive: true,
restore: true,
checkCrossOrigin: true,
checkOrientation: true,
scalable: true,
modal: true,
guides: true,
movable: true,
rotatable: true
}
const props = defineProps({
src: propTypes.string.def(''),
alt: propTypes.string.def(''),
@ -56,35 +29,21 @@ const props = defineProps({
default: undefined
},
imageStyle: { type: Object as PropType<CSSProperties>, default: () => ({}) },
options: { type: Object as PropType<Options>, default: () => ({}) }
options: { type: Object as PropType<Record<string, any>>, default: () => ({}) }
})
const emit = defineEmits(['cropend', 'ready', 'cropendError'])
const attrs = useAttrs()
const imgElRef = ref<ElRef<HTMLImageElement>>()
const cropper = ref<Nullable<Cropper>>()
const isReady = ref(false)
const imgElRef = ref<HTMLImageElement>()
const containerRef = ref<HTMLElement>()
const cropper = ref<Cropper>()
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('cropper-image')
const debounceRealTimeCroppered = useDebounceFn(realTimeCroppered, 80)
const getImageStyle = computed((): CSSProperties => {
return {
height: props.height,
maxWidth: '100%',
...props.imageStyle
}
})
const getClass = computed(() => {
return [
prefixCls,
attrs.class,
{
[`${prefixCls}--circled`]: props.circled
}
]
return [prefixCls, attrs.class]
})
const getWrapperStyle = computed((): CSSProperties => {
return { height: `${props.height}`.replace(/px/, '') + 'px' }
@ -98,27 +57,39 @@ onUnmounted(() => {
async function init() {
const imgEl = unref(imgElRef)
if (!imgEl) {
return
}
const containerEl = unref(containerRef)
if (!imgEl || !containerEl) return
cropper.value = new Cropper(imgEl, {
...defaultOptions,
ready: () => {
isReady.value = true
realTimeCroppered()
emit('ready', cropper.value)
},
crop() {
debounceRealTimeCroppered()
},
zoom() {
debounceRealTimeCroppered()
},
cropmove() {
debounceRealTimeCroppered()
},
container: containerEl,
...props.options
})
// Wait for custom elements to be ready, then configure
await nextTick()
const cropperSelection = cropper.value.getCropperSelection()
const cropperImage = cropper.value.getCropperImage()
if (cropperSelection) {
cropperSelection.initialCoverage = 0.5
cropperSelection.aspectRatio = 1
cropperSelection.movable = true
cropperSelection.resizable = true
cropperSelection.addEventListener('change', () => {
debounceRealTimeCroppered()
})
}
if (cropperImage) {
cropperImage.addEventListener('transform', () => {
debounceRealTimeCroppered()
})
// Emit ready once image loads
cropperImage.addEventListener('load', () => {
emit('ready', cropper.value)
realTimeCroppered()
})
}
}
// Real-time display preview
@ -127,33 +98,40 @@ function realTimeCroppered() {
}
// event: return base64 and width and height information after cropping
function croppered() {
if (!cropper.value) {
return
async function croppered() {
if (!cropper.value) return
const selection = cropper.value.getCropperSelection()
if (!selection) return
const imgInfo = { x: selection.x, y: selection.y, width: selection.width, height: selection.height }
try {
let canvas = await selection.$toCanvas()
if (props.circled) {
canvas = getRoundedCanvas(canvas)
}
canvas.toBlob((blob) => {
if (!blob) return
const fileReader = new FileReader()
fileReader.readAsDataURL(blob)
fileReader.onloadend = (e) => {
emit('cropend', {
imgBase64: e.target?.result ?? '',
imgInfo
})
}
fileReader.onerror = () => {
emit('cropendError')
}
}, 'image/png')
} catch {
// Selection may not be ready yet
}
let imgInfo = cropper.value.getData()
const canvas = props.circled ? getRoundedCanvas() : cropper.value.getCroppedCanvas()
canvas.toBlob((blob) => {
if (!blob) {
return
}
let fileReader: FileReader = new FileReader()
fileReader.readAsDataURL(blob)
fileReader.onloadend = (e) => {
emit('cropend', {
imgBase64: e.target?.result ?? '',
imgInfo
})
}
fileReader.onerror = () => {
emit('cropendError')
}
}, 'image/png')
}
// Get a circular picture canvas
function getRoundedCanvas() {
const sourceCanvas = cropper.value!.getCroppedCanvas()
function getRoundedCanvas(sourceCanvas: HTMLCanvasElement) {
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')!
const width = sourceCanvas.width
@ -169,15 +147,3 @@ function getRoundedCanvas() {
return canvas
}
</script>
<style lang="scss">
$prefix-cls: #{$namespace}-cropper-image;
.#{$prefix-cls} {
&--circled {
.cropper-view-box,
.cropper-face {
border-radius: 50%;
}
}
}
</style>

View File

@ -2,7 +2,7 @@ import type Cropper from 'cropperjs'
export interface CropendResult {
imgBase64: string
imgInfo: Cropper.Data
imgInfo: { x: number; y: number; width: number; height: number }
}
export type { Cropper }