|
@@ -0,0 +1,526 @@
|
|
|
+<template>
|
|
|
+ <div ref="containerRef" class="cropper-container">
|
|
|
+ <img
|
|
|
+ ref="imageRef"
|
|
|
+ :src="props.src"
|
|
|
+ alt="cropper image"
|
|
|
+ @load="handleImageLoad"
|
|
|
+ @mousedown="handleImageMouseDown"
|
|
|
+ />
|
|
|
+
|
|
|
+ <!-- 选择框 -->
|
|
|
+ <div
|
|
|
+ v-if="selection.visible"
|
|
|
+ class="selection-box"
|
|
|
+ :style="selectionStyle"
|
|
|
+ @mousedown="handleSelectionMouseDown"
|
|
|
+ @dblclick="handleDoubleClick"
|
|
|
+ >
|
|
|
+ <!-- 八个控制点 -->
|
|
|
+ <div
|
|
|
+ v-for="(handle, index) in handles"
|
|
|
+ :key="index"
|
|
|
+ :class="['resize-handle', handle.position]"
|
|
|
+ @mousedown="handleResizeMouseDown($event, handle.position)"
|
|
|
+ ></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+ import { ref, computed, onMounted, onUnmounted } from 'vue';
|
|
|
+
|
|
|
+ interface SelectionData {
|
|
|
+ x: number;
|
|
|
+ y: number;
|
|
|
+ width: number;
|
|
|
+ height: number;
|
|
|
+ visible: boolean;
|
|
|
+ }
|
|
|
+
|
|
|
+ interface HandlePosition {
|
|
|
+ position: string;
|
|
|
+ }
|
|
|
+
|
|
|
+ defineOptions({
|
|
|
+ name: 'Cropper',
|
|
|
+ });
|
|
|
+
|
|
|
+ const props = withDefaults(
|
|
|
+ defineProps<{
|
|
|
+ src: string;
|
|
|
+ }>(),
|
|
|
+ {
|
|
|
+ src: '',
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ const emit = defineEmits<{
|
|
|
+ crop: [data: { x: number; y: number; width: number; height: number }];
|
|
|
+ }>();
|
|
|
+
|
|
|
+ const containerRef = ref<HTMLDivElement>();
|
|
|
+ const imageRef = ref<HTMLImageElement>();
|
|
|
+
|
|
|
+ // 选择框数据
|
|
|
+ const selection = ref<SelectionData>({
|
|
|
+ x: 0,
|
|
|
+ y: 0,
|
|
|
+ width: 0,
|
|
|
+ height: 0,
|
|
|
+ visible: false,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 图片尺寸和位置
|
|
|
+ const imageRect = ref({
|
|
|
+ left: 0,
|
|
|
+ top: 0,
|
|
|
+ width: 0,
|
|
|
+ height: 0,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 拖拽状态
|
|
|
+ const dragState = ref({
|
|
|
+ isDragging: false,
|
|
|
+ isResizing: false,
|
|
|
+ resizeDirection: '',
|
|
|
+ startX: 0,
|
|
|
+ startY: 0,
|
|
|
+ startSelection: { x: 0, y: 0, width: 0, height: 0 },
|
|
|
+ });
|
|
|
+
|
|
|
+ // 控制点配置
|
|
|
+ const handles: HandlePosition[] = [
|
|
|
+ { position: 'nw' }, // 左上
|
|
|
+ { position: 'n' }, // 上
|
|
|
+ { position: 'ne' }, // 右上
|
|
|
+ { position: 'e' }, // 右
|
|
|
+ { position: 'se' }, // 右下
|
|
|
+ { position: 's' }, // 下
|
|
|
+ { position: 'sw' }, // 左下
|
|
|
+ { position: 'w' }, // 左
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 选择框样式
|
|
|
+ const selectionStyle = computed(() => ({
|
|
|
+ left: `${selection.value.x}px`,
|
|
|
+ top: `${selection.value.y}px`,
|
|
|
+ width: `${selection.value.width}px`,
|
|
|
+ height: `${selection.value.height}px`,
|
|
|
+ }));
|
|
|
+
|
|
|
+ // 图片加载完成
|
|
|
+ function handleImageLoad() {
|
|
|
+ updateImageRect();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新图片位置和尺寸
|
|
|
+ function updateImageRect() {
|
|
|
+ if (!imageRef.value || !containerRef.value) return;
|
|
|
+
|
|
|
+ const containerRect = containerRef.value.getBoundingClientRect();
|
|
|
+ const imgRect = imageRef.value.getBoundingClientRect();
|
|
|
+
|
|
|
+ imageRect.value = {
|
|
|
+ left: imgRect.left - containerRect.left,
|
|
|
+ top: imgRect.top - containerRect.top,
|
|
|
+ width: imgRect.width,
|
|
|
+ height: imgRect.height,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 图片鼠标按下事件
|
|
|
+ function handleImageMouseDown(event: MouseEvent) {
|
|
|
+ if (dragState.value.isDragging || dragState.value.isResizing) return;
|
|
|
+
|
|
|
+ event.preventDefault();
|
|
|
+
|
|
|
+ const rect = containerRef.value!.getBoundingClientRect();
|
|
|
+ const x = event.clientX - rect.left;
|
|
|
+ const y = event.clientY - rect.top;
|
|
|
+
|
|
|
+ // 开始新的选择
|
|
|
+ selection.value = {
|
|
|
+ x,
|
|
|
+ y,
|
|
|
+ width: 0,
|
|
|
+ height: 0,
|
|
|
+ visible: true,
|
|
|
+ };
|
|
|
+
|
|
|
+ dragState.value = {
|
|
|
+ isDragging: true,
|
|
|
+ isResizing: false,
|
|
|
+ resizeDirection: '',
|
|
|
+ startX: x,
|
|
|
+ startY: y,
|
|
|
+ startSelection: { ...selection.value },
|
|
|
+ };
|
|
|
+
|
|
|
+ document.addEventListener('mousemove', handleMouseMove);
|
|
|
+ document.addEventListener('mouseup', handleMouseUp);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 选择框鼠标按下事件(移动)
|
|
|
+ function handleSelectionMouseDown(event: MouseEvent) {
|
|
|
+ if (event.target !== event.currentTarget) return; // 避免控制点事件冒泡
|
|
|
+
|
|
|
+ event.preventDefault();
|
|
|
+ event.stopPropagation();
|
|
|
+
|
|
|
+ const rect = containerRef.value!.getBoundingClientRect();
|
|
|
+ const x = event.clientX - rect.left;
|
|
|
+ const y = event.clientY - rect.top;
|
|
|
+
|
|
|
+ dragState.value = {
|
|
|
+ isDragging: true,
|
|
|
+ isResizing: false,
|
|
|
+ resizeDirection: '',
|
|
|
+ startX: x,
|
|
|
+ startY: y,
|
|
|
+ startSelection: { ...selection.value },
|
|
|
+ };
|
|
|
+
|
|
|
+ document.addEventListener('mousemove', handleMouseMove);
|
|
|
+ document.addEventListener('mouseup', handleMouseUp);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 控制点鼠标按下事件(调整大小)
|
|
|
+ function handleResizeMouseDown(event: MouseEvent, direction: string) {
|
|
|
+ event.preventDefault();
|
|
|
+ event.stopPropagation();
|
|
|
+
|
|
|
+ const rect = containerRef.value!.getBoundingClientRect();
|
|
|
+ const x = event.clientX - rect.left;
|
|
|
+ const y = event.clientY - rect.top;
|
|
|
+
|
|
|
+ dragState.value = {
|
|
|
+ isDragging: false,
|
|
|
+ isResizing: true,
|
|
|
+ resizeDirection: direction,
|
|
|
+ startX: x,
|
|
|
+ startY: y,
|
|
|
+ startSelection: { ...selection.value },
|
|
|
+ };
|
|
|
+
|
|
|
+ document.addEventListener('mousemove', handleMouseMove);
|
|
|
+ document.addEventListener('mouseup', handleMouseUp);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 鼠标移动事件
|
|
|
+ function handleMouseMove(event: MouseEvent) {
|
|
|
+ if (!dragState.value.isDragging && !dragState.value.isResizing) return;
|
|
|
+
|
|
|
+ const rect = containerRef.value!.getBoundingClientRect();
|
|
|
+ const currentX = event.clientX - rect.left;
|
|
|
+ const currentY = event.clientY - rect.top;
|
|
|
+
|
|
|
+ if (dragState.value.isDragging && !dragState.value.isResizing) {
|
|
|
+ // 创建新选择或移动选择框
|
|
|
+ if (dragState.value.startSelection.width === 0) {
|
|
|
+ // 创建新选择
|
|
|
+ const width = Math.abs(currentX - dragState.value.startX);
|
|
|
+ const height = Math.abs(currentY - dragState.value.startY);
|
|
|
+ const x = Math.min(currentX, dragState.value.startX);
|
|
|
+ const y = Math.min(currentY, dragState.value.startY);
|
|
|
+
|
|
|
+ selection.value = {
|
|
|
+ x: Math.max(
|
|
|
+ imageRect.value.left,
|
|
|
+ Math.min(x, imageRect.value.left + imageRect.value.width - width)
|
|
|
+ ),
|
|
|
+ y: Math.max(
|
|
|
+ imageRect.value.top,
|
|
|
+ Math.min(y, imageRect.value.top + imageRect.value.height - height)
|
|
|
+ ),
|
|
|
+ width: Math.min(width, imageRect.value.width),
|
|
|
+ height: Math.min(height, imageRect.value.height),
|
|
|
+ visible: true,
|
|
|
+ };
|
|
|
+ } else {
|
|
|
+ // 移动选择框
|
|
|
+ const deltaX = currentX - dragState.value.startX;
|
|
|
+ const deltaY = currentY - dragState.value.startY;
|
|
|
+
|
|
|
+ let newX = dragState.value.startSelection.x + deltaX;
|
|
|
+ let newY = dragState.value.startSelection.y + deltaY;
|
|
|
+
|
|
|
+ // 边界限制
|
|
|
+ newX = Math.max(
|
|
|
+ imageRect.value.left,
|
|
|
+ Math.min(
|
|
|
+ newX,
|
|
|
+ imageRect.value.left + imageRect.value.width - selection.value.width
|
|
|
+ )
|
|
|
+ );
|
|
|
+ newY = Math.max(
|
|
|
+ imageRect.value.top,
|
|
|
+ Math.min(
|
|
|
+ newY,
|
|
|
+ imageRect.value.top +
|
|
|
+ imageRect.value.height -
|
|
|
+ selection.value.height
|
|
|
+ )
|
|
|
+ );
|
|
|
+
|
|
|
+ selection.value.x = newX;
|
|
|
+ selection.value.y = newY;
|
|
|
+ }
|
|
|
+ } else if (dragState.value.isResizing) {
|
|
|
+ // 调整选择框大小
|
|
|
+ resizeSelection(currentX, currentY);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 调整选择框大小
|
|
|
+ function resizeSelection(currentX: number, currentY: number) {
|
|
|
+ const { startSelection, resizeDirection, startX, startY } = dragState.value;
|
|
|
+ const deltaX = currentX - startX;
|
|
|
+ const deltaY = currentY - startY;
|
|
|
+
|
|
|
+ let newX = startSelection.x;
|
|
|
+ let newY = startSelection.y;
|
|
|
+ let newWidth = startSelection.width;
|
|
|
+ let newHeight = startSelection.height;
|
|
|
+
|
|
|
+ // 根据拖拽方向调整尺寸
|
|
|
+ if (resizeDirection.includes('n')) {
|
|
|
+ newY = startSelection.y + deltaY;
|
|
|
+ newHeight = startSelection.height - deltaY;
|
|
|
+ }
|
|
|
+ if (resizeDirection.includes('s')) {
|
|
|
+ newHeight = startSelection.height + deltaY;
|
|
|
+ }
|
|
|
+ if (resizeDirection.includes('w')) {
|
|
|
+ newX = startSelection.x + deltaX;
|
|
|
+ newWidth = startSelection.width - deltaX;
|
|
|
+ }
|
|
|
+ if (resizeDirection.includes('e')) {
|
|
|
+ newWidth = startSelection.width + deltaX;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 最小尺寸限制
|
|
|
+ const minSize = 10;
|
|
|
+ if (newWidth < minSize) {
|
|
|
+ if (resizeDirection.includes('w')) {
|
|
|
+ newX = startSelection.x + startSelection.width - minSize;
|
|
|
+ }
|
|
|
+ newWidth = minSize;
|
|
|
+ }
|
|
|
+ if (newHeight < minSize) {
|
|
|
+ if (resizeDirection.includes('n')) {
|
|
|
+ newY = startSelection.y + startSelection.height - minSize;
|
|
|
+ }
|
|
|
+ newHeight = minSize;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 边界限制
|
|
|
+ newX = Math.max(
|
|
|
+ imageRect.value.left,
|
|
|
+ Math.min(newX, imageRect.value.left + imageRect.value.width - newWidth)
|
|
|
+ );
|
|
|
+ newY = Math.max(
|
|
|
+ imageRect.value.top,
|
|
|
+ Math.min(newY, imageRect.value.top + imageRect.value.height - newHeight)
|
|
|
+ );
|
|
|
+ newWidth = Math.min(
|
|
|
+ newWidth,
|
|
|
+ imageRect.value.left + imageRect.value.width - newX
|
|
|
+ );
|
|
|
+ newHeight = Math.min(
|
|
|
+ newHeight,
|
|
|
+ imageRect.value.top + imageRect.value.height - newY
|
|
|
+ );
|
|
|
+
|
|
|
+ selection.value.x = newX;
|
|
|
+ selection.value.y = newY;
|
|
|
+ selection.value.width = newWidth;
|
|
|
+ selection.value.height = newHeight;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 鼠标抬起事件
|
|
|
+ function handleMouseUp() {
|
|
|
+ dragState.value.isDragging = false;
|
|
|
+ dragState.value.isResizing = false;
|
|
|
+
|
|
|
+ document.removeEventListener('mousemove', handleMouseMove);
|
|
|
+ document.removeEventListener('mouseup', handleMouseUp);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 双击事件
|
|
|
+ function handleDoubleClick() {
|
|
|
+ if (!selection.value.visible || !imageRef.value) return;
|
|
|
+
|
|
|
+ // 计算相对于显示图片的坐标
|
|
|
+ const relativeX = selection.value.x - imageRect.value.left;
|
|
|
+ const relativeY = selection.value.y - imageRect.value.top;
|
|
|
+
|
|
|
+ // 获取图片的原始尺寸和显示尺寸
|
|
|
+ const originalWidth = imageRef.value.naturalWidth;
|
|
|
+ const originalHeight = imageRef.value.naturalHeight;
|
|
|
+ const displayWidth = imageRect.value.width;
|
|
|
+ const displayHeight = imageRect.value.height;
|
|
|
+
|
|
|
+ // 计算缩放比例
|
|
|
+ const scaleX = originalWidth / displayWidth;
|
|
|
+ const scaleY = originalHeight / displayHeight;
|
|
|
+
|
|
|
+ // 转换为原始图片尺寸的坐标
|
|
|
+ const originalX = Math.round(relativeX * scaleX);
|
|
|
+ const originalY = Math.round(relativeY * scaleY);
|
|
|
+ const originalSelectionWidth = Math.round(selection.value.width * scaleX);
|
|
|
+ const originalSelectionHeight = Math.round(selection.value.height * scaleY);
|
|
|
+
|
|
|
+ // 计算相对于原图尺寸的相对值(0-1之间),保留4位小数
|
|
|
+ const relativeXRatio = parseFloat((originalX / originalWidth).toFixed(4));
|
|
|
+ const relativeYRatio = parseFloat((originalY / originalHeight).toFixed(4));
|
|
|
+ const relativeWidthRatio = parseFloat(
|
|
|
+ (originalSelectionWidth / originalWidth).toFixed(4)
|
|
|
+ );
|
|
|
+ const relativeHeightRatio = parseFloat(
|
|
|
+ (originalSelectionHeight / originalHeight).toFixed(4)
|
|
|
+ );
|
|
|
+
|
|
|
+ // 发送裁剪数据(相对于原图尺寸的相对值)
|
|
|
+ emit('crop', {
|
|
|
+ x: relativeXRatio,
|
|
|
+ y: relativeYRatio,
|
|
|
+ width: relativeWidthRatio,
|
|
|
+ height: relativeHeightRatio,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 清空选择
|
|
|
+ clearSelection();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清空选择
|
|
|
+ function clearSelection() {
|
|
|
+ selection.value.visible = false;
|
|
|
+ selection.value.x = 0;
|
|
|
+ selection.value.y = 0;
|
|
|
+ selection.value.width = 0;
|
|
|
+ selection.value.height = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 暴露方法
|
|
|
+ defineExpose({
|
|
|
+ clearSelection,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 监听窗口大小变化
|
|
|
+ function handleResize() {
|
|
|
+ updateImageRect();
|
|
|
+ }
|
|
|
+
|
|
|
+ onMounted(() => {
|
|
|
+ window.addEventListener('resize', handleResize);
|
|
|
+ });
|
|
|
+
|
|
|
+ onUnmounted(() => {
|
|
|
+ window.removeEventListener('resize', handleResize);
|
|
|
+ document.removeEventListener('mousemove', handleMouseMove);
|
|
|
+ document.removeEventListener('mouseup', handleMouseUp);
|
|
|
+ });
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+ .cropper-container {
|
|
|
+ position: relative;
|
|
|
+ display: inline-block;
|
|
|
+ user-select: none;
|
|
|
+
|
|
|
+ img {
|
|
|
+ display: block;
|
|
|
+ max-width: 100%;
|
|
|
+ max-height: 100%;
|
|
|
+ cursor: crosshair;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .selection-box {
|
|
|
+ position: absolute;
|
|
|
+ border: 2px dashed #409eff;
|
|
|
+ background: rgba(64, 158, 255, 0.2);
|
|
|
+ cursor: move;
|
|
|
+ box-sizing: border-box;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: #66b1ff;
|
|
|
+ background: rgba(102, 177, 255, 0.3);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .resize-handle {
|
|
|
+ position: absolute;
|
|
|
+ background: #409eff;
|
|
|
+ border: 1px solid #fff;
|
|
|
+ width: 8px;
|
|
|
+ height: 8px;
|
|
|
+ border-radius: 50%;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: #66b1ff;
|
|
|
+ transform: scale(1.2);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 左上角
|
|
|
+ &.nw {
|
|
|
+ top: -4px;
|
|
|
+ left: -4px;
|
|
|
+ cursor: nw-resize;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 上边中点
|
|
|
+ &.n {
|
|
|
+ top: -4px;
|
|
|
+ left: 50%;
|
|
|
+ transform: translateX(-50%);
|
|
|
+ cursor: n-resize;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 右上角
|
|
|
+ &.ne {
|
|
|
+ top: -4px;
|
|
|
+ right: -4px;
|
|
|
+ cursor: ne-resize;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 右边中点
|
|
|
+ &.e {
|
|
|
+ top: 50%;
|
|
|
+ right: -4px;
|
|
|
+ transform: translateY(-50%);
|
|
|
+ cursor: e-resize;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 右下角
|
|
|
+ &.se {
|
|
|
+ bottom: -4px;
|
|
|
+ right: -4px;
|
|
|
+ cursor: se-resize;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 下边中点
|
|
|
+ &.s {
|
|
|
+ bottom: -4px;
|
|
|
+ left: 50%;
|
|
|
+ transform: translateX(-50%);
|
|
|
+ cursor: s-resize;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 左下角
|
|
|
+ &.sw {
|
|
|
+ bottom: -4px;
|
|
|
+ left: -4px;
|
|
|
+ cursor: sw-resize;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 左边中点
|
|
|
+ &.w {
|
|
|
+ top: 50%;
|
|
|
+ left: -4px;
|
|
|
+ transform: translateY(-50%);
|
|
|
+ cursor: w-resize;
|
|
|
+ }
|
|
|
+ }
|
|
|
+</style>
|