This commit is contained in:
2025-11-21 20:10:06 +08:00
parent cf2b70bb1b
commit 76e1fc5894
@@ -1,217 +1,760 @@
<template> <template>
<div class="basic_container"> <div class="cropper-demo">
<div class="tool_wrap"> <div class="container">
<button :class="{ active: isCropperMove }" @click="handleMove"> <header>
移动 <h1>CropperJS 组件演示</h1>
</button> <p class="description">
<button @click="handleRotate">旋转</button> 此演示展示了CropperJS各个组件的功能和使用方法您可以通过下方控制面板调整裁剪区域查看预览效果并了解每个组件的作用
<button :class="{ active: isCropperSelection }" @click="handleCropper"> </p>
{{ isCropperSelection ? "重置选区" : "裁剪" }} </header>
</button>
</div> <div class="demo-area">
<div class="dialog_wrap"> <div class="cropper-container">
<div class="image_wrap" ref="imageWrap"> <h3>裁剪画布</h3>
<cropper-canvas ref="croppercanvas" background> <div class="canvas-wrapper">
<cropper-image <canvas
:src="fileObj.fileShow" ref="canvas"
alt="Picture" class="cropper-canvas"
ref="cropperimage" @mousedown="onCanvasMouseDown"
rotatable @mousemove="onCanvasMouseMove"
scalable @mouseup="onCanvasMouseUp"
skewable @mouseleave="onCanvasMouseLeave"
translatable ></canvas>
></cropper-image> </div>
<cropper-shade hidden ref="cropperShade"></cropper-shade>
<cropper-handle :action="currentType" plain></cropper-handle> <div class="viewer-container">
<cropper-selection <div class="viewer-label">实时预览</div>
id="cropperSelection" <canvas ref="previewCanvas" class="preview-viewer"></canvas>
ref="cropperselection" </div>
movable
resizable <div class="status-bar">
hidden <div>选区位置: <span>{{ positionInfo }}</span></div>
outlined <div>选区尺寸: <span>{{ sizeInfo }}</span></div>
@change="onCropperSelectionChange" <div>宽高比: <span>{{ ratioInfo }}</span></div>
> </div>
<cropper-crosshair centered /> </div>
<cropper-handle
action="move" <div class="controls">
theme-color="rgba(255, 255, 255, 0.35)" <h3>控制面板</h3>
/>
<cropper-handle action="n-resize" /> <div class="control-group">
<cropper-handle action="e-resize" /> <h4>选区操作</h4>
<cropper-handle action="s-resize" /> <div class="btn-group">
<cropper-handle action="w-resize" /> <button
<cropper-handle action="ne-resize" /> :class="{ active: interactionMode === 'move' }"
<cropper-handle action="nw-resize" /> @click="setInteractionMode('move')"
<cropper-handle action="se-resize" /> >
<cropper-handle action="sw-resize" /> 移动选区
</cropper-selection> </button>
</cropper-canvas> <button
:class="{ active: interactionMode === 'resize' }"
@click="setInteractionMode('resize')"
>
调整大小
</button>
<button @click="rotateSelection">旋转</button>
</div>
</div>
<div class="control-group">
<h4>宽高比设置</h4>
<div class="btn-group">
<button
v-for="ratio in aspectRatios"
:key="ratio.value"
@click="setAspectRatio(ratio.value)"
>
{{ ratio.label }}
</button>
</div>
</div>
<div class="control-group">
<h4>遮罩设置</h4>
<div class="slider-container">
<label for="opacitySlider">遮罩透明度: <span>{{ shade.opacity }}</span></label>
<input
type="range"
id="opacitySlider"
min="0"
max="1"
step="0.1"
v-model="shade.opacity"
>
</div>
<div class="btn-group">
<button
v-for="color in shadeColors"
:key="color.value"
@click="setShadeColor(color.value)"
>
{{ color.label }}
</button>
</div>
</div>
<div class="control-group">
<h4>十字准星</h4>
<div class="crosshair-controls">
<button @click="showCrosshair">显示准星</button>
<button @click="hideCrosshair">隐藏准星</button>
<button @click="moveCrosshair">移动准星</button>
</div>
</div>
<div class="control-group">
<h4>其他操作</h4>
<div class="btn-group">
<button @click="resetSelection">重置选区</button>
<button @click="cropImage">裁剪图片</button>
<button @click="downloadResult">下载结果</button>
</div>
</div>
</div>
</div> </div>
<div class="info_wrap">
<div class="cropper_preview"> <div class="component-info">
<cropper-viewer <h3>组件说明</h3>
selection="#cropperSelection" <div class="info-grid">
style="width: 200px" <div class="info-card" v-for="component in components" :key="component.name">
></cropper-viewer> <h4>{{ component.name }}</h4>
<p>{{ component.description }}</p>
</div>
</div> </div>
<div class="btn_wrap">
<input type="file" ref="input_form" @change="handleUploadSuccess" />
<button type="primary" @click="handleConfirm"> </button>
</div>
点击确认后看控制台有信息
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import "cropperjs"; import { ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { computed, ref } from "vue";
const fileObj = ref({}); // Refs
const canvas = ref(null)
const previewCanvas = ref(null)
const croppercanvas = ref(); // 响应式状态
const cropperimage = ref(); const selection = reactive({
const cropperselection = ref(); x: 100,
y: 100,
width: 300,
height: 200,
aspectRatio: 0,
isMoving: false,
isResizing: false,
startX: 0,
startY: 0
})
/** const shade = reactive({
* 选区逻辑 opacity: 0.5,
*/ color: '#000000'
// 是否正在开始选区 })
const isCropperSelection = ref(false);
const isCropperMove = ref(true);
// 判断当前是移动还是选区 const crosshair = reactive({
const currentType = computed(() => (isCropperMove.value ? "move" : "select")); visible: false,
x: 200,
y: 150
})
/** const interactionMode = ref('move')
* 按钮方法 const rotation = ref(0)
*/
// 旋转
function handleRotate() {
cropperimage.value.$rotate("90deg");
cropperimage.value.$center("contain");
}
// 裁剪
function handleCropper() {
isCropperMove.value = false;
if (isCropperMove.value) {
cropperselection.value.$clear();
} else {
const cropperCanvas = croppercanvas.value;
const cropperCanvasRect = cropperCanvas.getBoundingClientRect();
const cropperImage = cropperimage.value; // 计算属性
const cropperImageRect = cropperImage.getBoundingClientRect(); const positionInfo = computed(() => {
const maxSelection = { return `x: ${selection.x}, y: ${selection.y}`
x: cropperImageRect.left - cropperCanvasRect.left, })
y: cropperImageRect.top - cropperCanvasRect.top,
width: cropperImageRect.width, const sizeInfo = computed(() => {
height: cropperImageRect.height, return `${selection.width} × ${selection.height}`
}; })
cropperselection.value.$change(
maxSelection.x, const ratioInfo = computed(() => {
maxSelection.y, const ratio = (selection.width / selection.height).toFixed(2)
maxSelection.width, return selection.aspectRatio ?
maxSelection.height `${selection.aspectRatio}:1` :
); `${ratio}:1 (自由)`
})
// 常量数据
const aspectRatios = [
{ value: 0, label: '自由' },
{ value: 1, label: '1:1 (方形)' },
{ value: 1.777, label: '16:9 (宽屏)' },
{ value: 0.75, label: '3:4 (竖屏)' }
]
const shadeColors = [
{ value: '#000000', label: '黑色遮罩' },
{ value: '#3498db', label: '蓝色遮罩' },
{ value: '#e74c3c', label: '红色遮罩' }
]
const components = [
{
name: 'CropperImage',
description: '承载原始图片的基础组件,负责加载和显示图片,支持跨域处理和图片大小限制。'
},
{
name: 'CropperShade',
description: '创建遮罩层,突出显示裁剪区域,可调整透明度和颜色。'
},
{
name: 'CropperHandle',
description: '裁剪框的控制手柄,用于调整选区大小和旋转,支持八个方向的手柄。'
},
{
name: 'CropperCrosshair',
description: '十字准星,用于精确定位,可显示/隐藏和移动到指定位置。'
},
{
name: 'CropperSelection',
description: '管理裁剪选区,包括位置、大小和约束条件,支持宽高比锁定。'
},
{
name: 'CropperCanvas',
description: '画布容器,承载所有裁剪组件,提供绘制上下文和事件处理。'
},
{
name: 'CropperViewer',
description: '实时预览裁剪结果的视图,同步显示裁剪区域的内容。'
} }
]
// 图片引用
const img = new Image()
img.src = 'https://images.unsplash.com/photo-1506744038136-46273834b3fb'
// 方法
const setCanvasSize = () => {
if (!canvas.value) return
const container = canvas.value.parentElement
canvas.value.width = container.clientWidth
canvas.value.height = container.clientHeight
drawCanvas()
} }
// 移动
function handleMove() { const drawCanvas = () => {
if (!isCropperMove.value) { if (!canvas.value || !previewCanvas.value) return
isCropperMove.value = true;
// 如果想要点击移动,清除选区,可以打开下面的代码注释 const ctx = canvas.value.getContext('2d')
// cropperselection.value.$clear(); const previewCtx = previewCanvas.value.getContext('2d')
// 清空画布
ctx.clearRect(0, 0, canvas.value.width, canvas.value.height)
// 绘制背景
ctx.fillStyle = '#ecf0f1'
ctx.fillRect(0, 0, canvas.value.width, canvas.value.height)
// 绘制图片
if (img.complete) {
const scale = Math.min(
canvas.value.width / img.width,
canvas.value.height / img.height
)
const x = (canvas.value.width - img.width * scale) / 2
const y = (canvas.value.height - img.height * scale) / 2
ctx.save()
if (rotation.value !== 0) {
ctx.translate(canvas.value.width / 2, canvas.value.height / 2)
ctx.rotate(rotation.value * Math.PI / 180)
ctx.translate(-canvas.value.width / 2, -canvas.value.height / 2)
}
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
ctx.restore()
}
// 绘制遮罩
ctx.fillStyle = shade.color
ctx.globalAlpha = shade.opacity
ctx.fillRect(0, 0, canvas.value.width, canvas.value.height)
// 清除选区区域的遮罩
ctx.globalCompositeOperation = 'destination-out'
ctx.fillRect(
selection.x,
selection.y,
selection.width,
selection.height
)
ctx.globalCompositeOperation = 'source-over'
ctx.globalAlpha = 1
// 绘制选区边框
ctx.strokeStyle = '#3498db'
ctx.lineWidth = 2
ctx.strokeRect(
selection.x,
selection.y,
selection.width,
selection.height
)
// 绘制控制手柄
const handleSize = 8
ctx.fillStyle = '#3498db'
// 四角手柄
ctx.fillRect(
selection.x - handleSize/2,
selection.y - handleSize/2,
handleSize,
handleSize
)
ctx.fillRect(
selection.x + selection.width - handleSize/2,
selection.y - handleSize/2,
handleSize,
handleSize
)
ctx.fillRect(
selection.x - handleSize/2,
selection.y + selection.height - handleSize/2,
handleSize,
handleSize
)
ctx.fillRect(
selection.x + selection.width - handleSize/2,
selection.y + selection.height - handleSize/2,
handleSize,
handleSize
)
// 四边手柄
ctx.fillRect(
selection.x + selection.width/2 - handleSize/2,
selection.y - handleSize/2,
handleSize,
handleSize
)
ctx.fillRect(
selection.x + selection.width/2 - handleSize/2,
selection.y + selection.height - handleSize/2,
handleSize,
handleSize
)
ctx.fillRect(
selection.x - handleSize/2,
selection.y + selection.height/2 - handleSize/2,
handleSize,
handleSize
)
ctx.fillRect(
selection.x + selection.width - handleSize/2,
selection.y + selection.height/2 - handleSize/2,
handleSize,
handleSize
)
// 绘制十字准星
if (crosshair.visible) {
ctx.strokeStyle = '#e74c3c'
ctx.lineWidth = 1
ctx.setLineDash([5, 5])
// 横线
ctx.beginPath()
ctx.moveTo(0, crosshair.y)
ctx.lineTo(canvas.value.width, crosshair.y)
ctx.stroke()
// 竖线
ctx.beginPath()
ctx.moveTo(crosshair.x, 0)
ctx.lineTo(crosshair.x, canvas.value.height)
ctx.stroke()
ctx.setLineDash([])
// 中心点
ctx.fillStyle = '#e74c3c'
ctx.beginPath()
ctx.arc(crosshair.x, crosshair.y, 4, 0, Math.PI * 2)
ctx.fill()
}
// 更新预览
updatePreview()
}
const updatePreview = () => {
if (!previewCanvas.value || !canvas.value) return
const previewCtx = previewCanvas.value.getContext('2d')
previewCtx.clearRect(0, 0, previewCanvas.value.width, previewCanvas.value.height)
if (img.complete) {
const scale = Math.min(
canvas.value.width / img.width,
canvas.value.height / img.height
)
const imgX = (canvas.value.width - img.width * scale) / 2
const imgY = (canvas.value.height - img.height * scale) / 2
// 计算源图像中的对应区域
const srcX = (selection.x - imgX) / scale
const srcY = (selection.y - imgY) / scale
const srcWidth = selection.width / scale
const srcHeight = selection.height / scale
// 绘制到预览画布
previewCtx.drawImage(
img,
srcX, srcY, srcWidth, srcHeight,
0, 0, previewCanvas.value.width, previewCanvas.value.height
)
// 绘制预览边框
previewCtx.strokeStyle = '#3498db'
previewCtx.lineWidth = 2
previewCtx.strokeRect(0, 0, previewCanvas.value.width, previewCanvas.value.height)
} }
} }
/** const setAspectRatio = (ratio) => {
* 监听选择区变化 selection.aspectRatio = ratio
* @param event if (ratio > 0) {
*/ selection.height = selection.width / ratio
function onCropperSelectionChange(event) { }
if (event.detail.width && event.detail.height) { drawCanvas()
isCropperSelection.value = true; }
} else {
isCropperSelection.value = false; const setShadeColor = (color) => {
shade.color = color
drawCanvas()
}
const setInteractionMode = (mode) => {
interactionMode.value = mode
}
const showCrosshair = () => {
crosshair.visible = true
drawCanvas()
}
const hideCrosshair = () => {
crosshair.visible = false
drawCanvas()
}
const moveCrosshair = () => {
crosshair.x = Math.random() * canvas.value.width
crosshair.y = Math.random() * canvas.value.height
drawCanvas()
}
const resetSelection = () => {
selection.x = 100
selection.y = 100
selection.width = 300
selection.height = 200
selection.aspectRatio = 0
rotation.value = 0
drawCanvas()
}
const rotateSelection = () => {
rotation.value = (rotation.value + 90) % 360
drawCanvas()
}
const cropImage = () => {
alert('裁剪功能已触发!在实际应用中,这里会执行裁剪操作。')
}
const downloadResult = () => {
alert('下载功能已触发!在实际应用中,这里会下载裁剪后的图片。')
}
// 画布事件处理
const onCanvasMouseDown = (e) => {
if (!canvas.value) return
const rect = canvas.value.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// 检查是否点击在选区内
if (x >= selection.x && x <= selection.x + selection.width &&
y >= selection.y && y <= selection.y + selection.height) {
selection.isMoving = true
selection.startX = x - selection.x
selection.startY = y - selection.y
} }
} }
/** const onCanvasMouseMove = (e) => {
* 确认裁剪 if (selection.isMoving && canvas.value) {
*/ const rect = canvas.value.getBoundingClientRect()
const emit = defineEmits(["success"]); const x = e.clientX - rect.left
async function handleConfirm() { const y = e.clientY - rect.top
if (isCropperSelection.value) {
const res = await cropperselection.value.$toCanvas();
const dataImage = res.toDataURL("image/png"); selection.x = x - selection.startX
const file = dataURLtoFile(dataImage, fileObj.value.name); selection.y = y - selection.startY
emit("success", {
...fileObj.value, // 限制选区不超出画布
file: file, selection.x = Math.max(
fileShow: dataImage, 0,
}); Math.min(canvas.value.width - selection.width, selection.x)
)
selection.y = Math.max(
0,
Math.min(canvas.value.height - selection.height, selection.y)
)
drawCanvas()
} }
} }
// 将data:image转成新的file
function dataURLtoFile(dataurl, filename) { const onCanvasMouseUp = () => {
var arr = dataurl.split(","), selection.isMoving = false
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
const blob = new Blob([u8arr], { type: mime });
const file = new File([blob], filename, { type: mime });
return file;
} }
/** const onCanvasMouseLeave = () => {
* 文件上传 selection.isMoving = false
*/
const input_form = ref();
function handleUploadSuccess() {
const files = input_form.value.files;
if (files.length) {
fileObj.value = {
name: files[0].name,
file: files[0],
fileShow: URL.createObjectURL(files[0]),
};
}
} }
// 生命周期
onMounted(() => {
setCanvasSize()
window.addEventListener('resize', setCanvasSize)
img.onload = () => {
drawCanvas()
}
})
onUnmounted(() => {
window.removeEventListener('resize', setCanvasSize)
})
// 监听器
watch([shade, selection], () => {
drawCanvas()
}, { deep: true })
watch(() => shade.opacity, () => {
drawCanvas()
})
</script> </script>
<style scoped> <style scoped>
.dialog_wrap { .cropper-demo {
display: flex; min-height: 100vh;
.image_wrap { background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
width: 300px; padding: 20px;
height: 300px; color: #333;
flex-shrink: 0; }
cropper-canvas { .container {
width: 100%; max-width: 1200px;
height: 100%; margin: 0 auto;
}
}
.info_wrap {
margin-left: 20px;
}
} }
header {
text-align: center;
margin-bottom: 30px;
padding: 20px;
background: rgba(255, 255, 255, 0.8);
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
h1 {
color: #2c3e50;
margin-bottom: 10px;
}
.description {
color: #7f8c8d;
max-width: 800px;
margin: 0 auto;
line-height: 1.6;
}
.demo-area {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 30px;
}
.cropper-container {
flex: 1;
min-width: 500px;
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.controls {
flex: 0 0 300px;
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.canvas-wrapper {
position: relative;
width: 100%;
height: 400px;
border: 2px dashed #bdc3c7;
border-radius: 5px;
overflow: hidden;
margin-bottom: 20px;
background: #ecf0f1;
}
.cropper-canvas {
width: 100%;
height: 100%;
}
.viewer-container {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 20px;
}
.viewer-label {
font-weight: bold;
margin-bottom: 10px;
color: #2c3e50;
}
.preview-viewer {
width: 200px;
height: 150px;
border: 2px solid #3498db;
border-radius: 5px;
overflow: hidden;
background: white;
}
.control-group {
margin-bottom: 20px;
}
h3 {
color: #2c3e50;
margin-bottom: 15px;
padding-bottom: 5px;
border-bottom: 1px solid #ecf0f1;
}
.btn-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
button { button {
& + button { padding: 10px 15px;
margin-left: 20px; border: none;
} border-radius: 5px;
background: #3498db;
color: white;
cursor: pointer;
transition: all 0.3s;
font-weight: 600;
flex: 1;
min-width: 80px;
} }
button:hover {
background: #2980b9;
transform: translateY(-2px);
}
button.active { button.active {
background-color: #c6dff8; background: #e74c3c;
border-color: #409eff; }
.slider-container {
margin: 15px 0;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
input[type="range"] {
width: 100%;
}
.component-info {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
margin-top: 20px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 15px;
}
.info-card {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
border-left: 4px solid #3498db;
}
.info-card h4 {
color: #2c3e50;
margin-bottom: 10px;
}
.info-card p {
color: #7f8c8d;
font-size: 0.9rem;
line-height: 1.5;
}
.status-bar {
display: flex;
justify-content: space-between;
margin-top: 10px;
font-size: 0.9rem;
color: #7f8c8d;
}
.crosshair-controls {
display: flex;
gap: 10px;
align-items: center;
}
.crosshair-controls button {
padding: 8px 12px;
font-size: 0.9rem;
}
@media (max-width: 900px) {
.demo-area {
flex-direction: column;
}
.cropper-container, .controls {
min-width: 100%;
}
.status-bar {
flex-direction: column;
gap: 5px;
}
} }
</style> </style>