feat: 裁剪组件

This commit is contained in:
2026-04-21 16:13:55 +08:00
parent 429c7db195
commit ec632554e2
3 changed files with 178 additions and 93 deletions

View File

@@ -28,13 +28,14 @@
:ratio="data.ratio"
:fixedBox="type !== 'apparel'"
:url="data.url"
:type="type"
@change="(v) => (data.preview_url = v)"
/>
<div
class="preview"
:class="{
'is-product': data.isProduct,
'is-apprael': type === 'apprael',
'is-apparel': type === 'apparel',
'is-cover': type === 'cover'
}"
v-if="data.isPreview"
@@ -196,10 +197,15 @@ defineExpose({
box-shadow: 4px 4px 16px 0px #0000000f;
border: 1px solid #ededed;
}
&.is-cover{
img{
&.is-cover {
img {
// width: 29.7rem;
// height: 37.5rem;
height: 37.5rem;
}
}
&.is-apparel {
img {
height: auto;
}
}
}

View File

@@ -1,19 +1,21 @@
<template>
<div class="image-clip" :class="{ 'is-product': isProduct }">
<div class="image-clip-body" ref="imageClipBody">
<div class="image-clip" :class="{ 'is-product': isProduct }" :data-crop-type="type || ''">
<div class="image-clip-body" :class="{ 'is-cover': type === 'cover' }" ref="imageClipBody">
<VueCropper
ref="cropper"
:img="url"
crossOrigin="Anonymous"
:autoCrop="true"
:fixedNumber="ratio"
fixed
:fixed="type !== 'apparel'"
movable
centerBox
:fixedBox="fixedBox"
@realTime="onChange"
v-bind="$attrs"
outputType="png"
:autoCropWidth="type === 'cover' ? 297 : 242"
:autoCropHeight="autoCropHeight"
></VueCropper>
</div>
<div class="clip_opterate">
@@ -36,7 +38,7 @@
</div>
</template>
<script setup>
import { ref, useAttrs, onMounted, onBeforeUnmount } from "vue"
import { ref, useAttrs, onMounted, onBeforeUnmount, computed, nextTick, watch } from "vue"
import "vue-cropper/dist/index.css"
import { VueCropper } from "vue-cropper"
const props = defineProps({
@@ -54,11 +56,22 @@ const props = defineProps({
},
fixedBox: {
type: Boolean,
default:true
default: true
},
type: {
type: String,
default: () => ""
}
})
const attrs = useAttrs()
const autoCropHeight = computed(() => {
let height = 426
if (props.type === "cover") height = 375
else if (props.type === "apparel") height = 320
return height
})
const onChange = (data) => {
if (attrs.onChange) {
getCropUrl().then((url) => attrs.onChange(url))
@@ -66,15 +79,77 @@ const onChange = (data) => {
}
const cropper = ref(null)
const imageClipBody = ref(null)
let injectLabelFrame = 0
const observer = new ResizeObserver((entries) => {
refreshCrop()
})
const clearCropLabels = (cropperBox) => {
if (!cropperBox) return
cropperBox.querySelectorAll(".cropper-line-label").forEach((node) => node.remove())
}
const createCropLabel = ({ text, top, className }) => {
const label = document.createElement("div")
label.className = `cropper-line-label ${className}`
label.textContent = text
label.style.top = top
label.style.left = className === "label-v" ? "50%" : "0"
label.style.transform =
className === "label-v" ? "translate(-50%, -50%)" : "translateY(-50%)"
return label
}
const cropLabelMap = {
cover: [
{ text: "crown", top: "2.67%", className: "label-h" },
{ text: "hip line", top: "63.47%", className: "label-h" },
{ text: "mid-thigh", top: "92.8%", className: "label-h" },
{ text: "center", top: "0", className: "label-v" }
],
mainProductImage: [
{ text: "crown", top: "2.67%", className: "label-h" },
{ text: "footbase", top: "97.6%", className: "label-h" },
{ text: "center", top: "0", className: "label-v" }
],
sketch: [
{ text: "crown", top: "2.67%", className: "label-h" },
{ text: "footbase", top: "97.6%", className: "label-h" },
{ text: "center", top: "0", className: "label-v" }
],
apparel: [{ text: "center", top: "0", className: "label-v" }]
}
const injectCropLabel = () => {
const cropperBox = imageClipBody.value?.querySelector(".cropper-view-box")
if (!cropperBox) return false
clearCropLabels(cropperBox)
;(cropLabelMap[props.type] || []).forEach((config) => {
cropperBox.appendChild(createCropLabel(config))
})
return true
}
const scheduleInjectCropLabel = (retry = 0) => {
cancelAnimationFrame(injectLabelFrame)
injectLabelFrame = requestAnimationFrame(() => {
if (!injectCropLabel() && retry < 10) {
scheduleInjectCropLabel(retry + 1)
}
})
}
onMounted(() => {
observer.observe(imageClipBody.value)
injectCropLabel()
scheduleInjectCropLabel()
})
onBeforeUnmount(() => {
observer.disconnect()
cancelAnimationFrame(injectLabelFrame)
})
const rotateLeft = () => {
cropper.value.rotateLeft()
@@ -99,47 +174,14 @@ const getCropBlob = () => {
})
}
const injectCropLabel = () => {
const cropperBox = imageClipBody.value.querySelector(".cropper-view-box")
if (cropperBox) {
// Add crown label
const crownLabel = document.createElement("div")
crownLabel.className = "cropper-line-label label-h"
crownLabel.textContent = "crown"
crownLabel.style.top = "2.67%"
crownLabel.style.left = "0"
crownLabel.style.transform = "translateY(-50%)"
cropperBox.appendChild(crownLabel)
// Add hip line label
const hipLabel = document.createElement("div")
hipLabel.className = "cropper-line-label label-h"
hipLabel.textContent = "hip line"
hipLabel.style.top = "63.47%"
hipLabel.style.left = "0"
hipLabel.style.transform = "translateY(-50%)"
cropperBox.appendChild(hipLabel)
// Add mid-thigh label
const thighLabel = document.createElement("div")
thighLabel.className = "cropper-line-label label-h"
thighLabel.textContent = "mid-thigh"
thighLabel.style.top = "92.8%"
thighLabel.style.left = "0"
thighLabel.style.transform = "translateY(-50%)"
cropperBox.appendChild(thighLabel)
// Add center label
const centerLabel = document.createElement("div")
centerLabel.className = "cropper-line-label label-v"
centerLabel.textContent = "center"
centerLabel.style.top = "0"
centerLabel.style.left = "50%"
centerLabel.style.transform = "translate(-50%, -50%)"
cropperBox.appendChild(centerLabel)
}
}
watch(
[() => props.type, () => props.url],
async () => {
await nextTick()
scheduleInjectCropLabel()
},
{ flush: "post" }
)
defineExpose({
getCropUrl,
@@ -172,6 +214,19 @@ defineExpose({
}
}
}
&.is-cover {
:deep(.vue-cropper) {
overflow: hidden;
}
:deep(.cropper-box-canvas) {
width: 31.1rem !important;
left: 50% !important;
transform: translateX(-50%) !important;
img {
display: none;
}
}
}
}
.clip_opterate {
@@ -218,52 +273,75 @@ defineExpose({
}
}
&.is-product {
.image-clip-body {
width: 45.7rem;
height: 45.7rem;
:deep(.cropper-modal) {
background: transparent;
}
:deep(.vue-cropper .cropper-view-box) {
position: relative;
overflow: visible !important;
/* 原有的蓝色边框outline由组件控制这里不干涉 */
&.is-product {
.image-clip-body {
width: 45.7rem;
height: 45.7rem;
:deep(.cropper-modal) {
background: transparent;
}
:deep(.vue-cropper .cropper-view-box) {
position: relative;
overflow: visible !important;
/* 原有的蓝色边框outline由组件控制这里不干涉 */
}
:deep(.vue-cropper .cropper-view-box::after) {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9; /* 位于图片之上,但在控制点之下 */
background-image: none;
background-repeat: no-repeat;
}
}
:deep(.vue-cropper .cropper-view-box::after) {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9; /* 位于图片之上,但在控制点之下 */
&[data-crop-type="cover"] {
.image-clip-body {
:deep(.vue-cropper .cropper-view-box::after) {
background-image:
linear-gradient(to right, #4ba5ff 50%, transparent 50%),
linear-gradient(to right, #4ba5ff 50%, transparent 50%),
linear-gradient(to right, #4ba5ff 50%, transparent 50%),
linear-gradient(to bottom, #4ba5ff 50%, transparent 50%);
background-repeat: repeat-x, repeat-x, repeat-x, repeat-y;
background-size: 8px 1px, 8px 1px, 8px 1px, 1px 8px;
background-position: 0 2.67%, 0 63.47%, 0 92.8%, 50% 0;
}
}
}
/* 绘制 4 条 #4BA5FF 的虚线 */
background-image:
linear-gradient(to right, #4ba5ff 50%, transparent 50%),
/* Crown */ linear-gradient(to right, #4ba5ff 50%, transparent 50%),
/* Hip line */ linear-gradient(to right, #4ba5ff 50%, transparent 50%),
/* Mid-thigh */ linear-gradient(to bottom, #4ba5ff 50%, transparent 50%); /* Center */
&[data-crop-type="mainProductImage"],
&[data-crop-type="sketch"] {
.image-clip-body {
:deep(.vue-cropper .cropper-view-box::after) {
background-image:
linear-gradient(to right, #4ba5ff 50%, transparent 50%),
linear-gradient(to right, #4ba5ff 50%, transparent 50%),
linear-gradient(to bottom, #4ba5ff 50%, transparent 50%);
background-repeat: repeat-x, repeat-x, repeat-y;
background-size: 8px 1px, 8px 1px, 1px 8px;
background-position: 0 2.67%, 0 97.6%, 50% 0;
}
}
}
background-repeat: repeat-x, repeat-x, repeat-x, repeat-y;
background-size:
8px 1px,
8px 1px,
8px 1px,
1px 8px; /* 虚线间距 */
background-position:
0 2.67%,
0 63.47%,
0 92.8%,
50% 0;
&[data-crop-type="apparel"] {
.image-clip-body {
:deep(.vue-cropper .cropper-view-box::after) {
background-image: linear-gradient(to bottom, #4ba5ff 50%, transparent 50%);
background-repeat: repeat-y;
background-size: 1px 8px;
background-position: 50% 0;
}
}
}
}
}
}
</style>
<style lang="less">

View File

@@ -224,7 +224,6 @@
fixedBox
isProduct
:info="false"
:autoCropWidth="90"
v-bind="$attrs"
:type="cropType"
/>
@@ -405,15 +404,17 @@ const handleClickCrop = (data, type) => {
const titleList = {
sketch: "Crop Sketch",
mainProductImage: "Crop Main Product Image",
cover: "Crop Cover"
cover: "Crop Cover",
apparel: "Crop Apparel Sketch"
}
const ratio = type === "apparel" ? [4, 5] : [9, 16]
cropType.value = type
imageClipDialogRef.value.open(
data,
(file) => {
selectList.value[currentIndex.value].sketch = URL.createObjectURL(file)
},
{ ratio: [9, 16], isPreview: true, title: titleList[type], isProduct: true }
{ ratio, isPreview: true, title: titleList[type], isProduct: true }
)
}