feat: 裁剪组件
This commit is contained in:
@@ -28,13 +28,14 @@
|
|||||||
:ratio="data.ratio"
|
:ratio="data.ratio"
|
||||||
:fixedBox="type !== 'apparel'"
|
:fixedBox="type !== 'apparel'"
|
||||||
:url="data.url"
|
:url="data.url"
|
||||||
|
:type="type"
|
||||||
@change="(v) => (data.preview_url = v)"
|
@change="(v) => (data.preview_url = v)"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="preview"
|
class="preview"
|
||||||
:class="{
|
:class="{
|
||||||
'is-product': data.isProduct,
|
'is-product': data.isProduct,
|
||||||
'is-apprael': type === 'apprael',
|
'is-apparel': type === 'apparel',
|
||||||
'is-cover': type === 'cover'
|
'is-cover': type === 'cover'
|
||||||
}"
|
}"
|
||||||
v-if="data.isPreview"
|
v-if="data.isPreview"
|
||||||
@@ -196,10 +197,15 @@ defineExpose({
|
|||||||
box-shadow: 4px 4px 16px 0px #0000000f;
|
box-shadow: 4px 4px 16px 0px #0000000f;
|
||||||
border: 1px solid #ededed;
|
border: 1px solid #ededed;
|
||||||
}
|
}
|
||||||
&.is-cover{
|
&.is-cover {
|
||||||
img{
|
img {
|
||||||
// width: 29.7rem;
|
// width: 29.7rem;
|
||||||
// height: 37.5rem;
|
height: 37.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.is-apparel {
|
||||||
|
img {
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="image-clip" :class="{ 'is-product': isProduct }">
|
<div class="image-clip" :class="{ 'is-product': isProduct }" :data-crop-type="type || ''">
|
||||||
<div class="image-clip-body" ref="imageClipBody">
|
<div class="image-clip-body" :class="{ 'is-cover': type === 'cover' }" ref="imageClipBody">
|
||||||
<VueCropper
|
<VueCropper
|
||||||
ref="cropper"
|
ref="cropper"
|
||||||
:img="url"
|
:img="url"
|
||||||
crossOrigin="Anonymous"
|
crossOrigin="Anonymous"
|
||||||
:autoCrop="true"
|
:autoCrop="true"
|
||||||
:fixedNumber="ratio"
|
:fixedNumber="ratio"
|
||||||
fixed
|
:fixed="type !== 'apparel'"
|
||||||
movable
|
movable
|
||||||
centerBox
|
centerBox
|
||||||
:fixedBox="fixedBox"
|
:fixedBox="fixedBox"
|
||||||
@realTime="onChange"
|
@realTime="onChange"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
outputType="png"
|
outputType="png"
|
||||||
|
:autoCropWidth="type === 'cover' ? 297 : 242"
|
||||||
|
:autoCropHeight="autoCropHeight"
|
||||||
></VueCropper>
|
></VueCropper>
|
||||||
</div>
|
</div>
|
||||||
<div class="clip_opterate">
|
<div class="clip_opterate">
|
||||||
@@ -36,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<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 "vue-cropper/dist/index.css"
|
||||||
import { VueCropper } from "vue-cropper"
|
import { VueCropper } from "vue-cropper"
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -54,11 +56,22 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
fixedBox: {
|
fixedBox: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default:true
|
default: true
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: () => ""
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const attrs = useAttrs()
|
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) => {
|
const onChange = (data) => {
|
||||||
if (attrs.onChange) {
|
if (attrs.onChange) {
|
||||||
getCropUrl().then((url) => attrs.onChange(url))
|
getCropUrl().then((url) => attrs.onChange(url))
|
||||||
@@ -66,15 +79,77 @@ const onChange = (data) => {
|
|||||||
}
|
}
|
||||||
const cropper = ref(null)
|
const cropper = ref(null)
|
||||||
const imageClipBody = ref(null)
|
const imageClipBody = ref(null)
|
||||||
|
let injectLabelFrame = 0
|
||||||
const observer = new ResizeObserver((entries) => {
|
const observer = new ResizeObserver((entries) => {
|
||||||
refreshCrop()
|
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(() => {
|
onMounted(() => {
|
||||||
observer.observe(imageClipBody.value)
|
observer.observe(imageClipBody.value)
|
||||||
injectCropLabel()
|
scheduleInjectCropLabel()
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
observer.disconnect()
|
observer.disconnect()
|
||||||
|
cancelAnimationFrame(injectLabelFrame)
|
||||||
})
|
})
|
||||||
const rotateLeft = () => {
|
const rotateLeft = () => {
|
||||||
cropper.value.rotateLeft()
|
cropper.value.rotateLeft()
|
||||||
@@ -99,47 +174,14 @@ const getCropBlob = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const injectCropLabel = () => {
|
watch(
|
||||||
const cropperBox = imageClipBody.value.querySelector(".cropper-view-box")
|
[() => props.type, () => props.url],
|
||||||
|
async () => {
|
||||||
if (cropperBox) {
|
await nextTick()
|
||||||
// Add crown label
|
scheduleInjectCropLabel()
|
||||||
const crownLabel = document.createElement("div")
|
},
|
||||||
crownLabel.className = "cropper-line-label label-h"
|
{ flush: "post" }
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
getCropUrl,
|
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 {
|
.clip_opterate {
|
||||||
@@ -218,52 +273,75 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-product {
|
&.is-product {
|
||||||
.image-clip-body {
|
.image-clip-body {
|
||||||
width: 45.7rem;
|
width: 45.7rem;
|
||||||
height: 45.7rem;
|
height: 45.7rem;
|
||||||
:deep(.cropper-modal) {
|
:deep(.cropper-modal) {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
:deep(.vue-cropper .cropper-view-box) {
|
:deep(.vue-cropper .cropper-view-box) {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
/* 原有的蓝色边框(outline)由组件控制,这里不干涉 */
|
/* 原有的蓝色边框(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) {
|
&[data-crop-type="cover"] {
|
||||||
content: "";
|
.image-clip-body {
|
||||||
position: absolute;
|
:deep(.vue-cropper .cropper-view-box::after) {
|
||||||
top: 0;
|
background-image:
|
||||||
left: 0;
|
linear-gradient(to right, #4ba5ff 50%, transparent 50%),
|
||||||
width: 100%;
|
linear-gradient(to right, #4ba5ff 50%, transparent 50%),
|
||||||
height: 100%;
|
linear-gradient(to right, #4ba5ff 50%, transparent 50%),
|
||||||
pointer-events: none;
|
linear-gradient(to bottom, #4ba5ff 50%, transparent 50%);
|
||||||
z-index: 9; /* 位于图片之上,但在控制点之下 */
|
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 的虚线 */
|
&[data-crop-type="mainProductImage"],
|
||||||
background-image:
|
&[data-crop-type="sketch"] {
|
||||||
linear-gradient(to right, #4ba5ff 50%, transparent 50%),
|
.image-clip-body {
|
||||||
/* Crown */ linear-gradient(to right, #4ba5ff 50%, transparent 50%),
|
:deep(.vue-cropper .cropper-view-box::after) {
|
||||||
/* Hip line */ linear-gradient(to right, #4ba5ff 50%, transparent 50%),
|
background-image:
|
||||||
/* Mid-thigh */ linear-gradient(to bottom, #4ba5ff 50%, transparent 50%); /* Center */
|
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;
|
&[data-crop-type="apparel"] {
|
||||||
background-size:
|
.image-clip-body {
|
||||||
8px 1px,
|
:deep(.vue-cropper .cropper-view-box::after) {
|
||||||
8px 1px,
|
background-image: linear-gradient(to bottom, #4ba5ff 50%, transparent 50%);
|
||||||
8px 1px,
|
background-repeat: repeat-y;
|
||||||
1px 8px; /* 虚线间距 */
|
background-size: 1px 8px;
|
||||||
|
background-position: 50% 0;
|
||||||
background-position:
|
}
|
||||||
0 2.67%,
|
}
|
||||||
0 63.47%,
|
|
||||||
0 92.8%,
|
|
||||||
50% 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="less">
|
<style lang="less">
|
||||||
|
|||||||
@@ -224,7 +224,6 @@
|
|||||||
fixedBox
|
fixedBox
|
||||||
isProduct
|
isProduct
|
||||||
:info="false"
|
:info="false"
|
||||||
:autoCropWidth="90"
|
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
:type="cropType"
|
:type="cropType"
|
||||||
/>
|
/>
|
||||||
@@ -405,15 +404,17 @@ const handleClickCrop = (data, type) => {
|
|||||||
const titleList = {
|
const titleList = {
|
||||||
sketch: "Crop Sketch",
|
sketch: "Crop Sketch",
|
||||||
mainProductImage: "Crop Main Product Image",
|
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
|
cropType.value = type
|
||||||
imageClipDialogRef.value.open(
|
imageClipDialogRef.value.open(
|
||||||
data,
|
data,
|
||||||
(file) => {
|
(file) => {
|
||||||
selectList.value[currentIndex.value].sketch = URL.createObjectURL(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 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user