|
|
@@ -0,0 +1,1181 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import type { Recordable } from '@vben/types';
|
|
|
+
|
|
|
+import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
|
|
+
|
|
|
+import {
|
|
|
+ useVbenModal,
|
|
|
+ VbenButton,
|
|
|
+ VbenButtonGroup,
|
|
|
+ VbenIconButton,
|
|
|
+} from '@vben/common-ui';
|
|
|
+
|
|
|
+import { Image, Input } from 'ant-design-vue';
|
|
|
+
|
|
|
+import { FileApi } from '#/api';
|
|
|
+import { Icon } from '#/components/icon';
|
|
|
+
|
|
|
+interface Position {
|
|
|
+ x: number;
|
|
|
+ y: number;
|
|
|
+}
|
|
|
+
|
|
|
+interface Size {
|
|
|
+ width: number;
|
|
|
+ height: number;
|
|
|
+}
|
|
|
+
|
|
|
+interface FramePosition {
|
|
|
+ left: number;
|
|
|
+ top: number;
|
|
|
+}
|
|
|
+
|
|
|
+interface AspectRatio {
|
|
|
+ value: number;
|
|
|
+ label: string;
|
|
|
+}
|
|
|
+
|
|
|
+type ResizeHandlePosition =
|
|
|
+ | 'bottom-left'
|
|
|
+ | 'bottom-right'
|
|
|
+ | 'top-left'
|
|
|
+ | 'top-right';
|
|
|
+type MoveDirection = 'down' | 'left' | 'right' | 'up';
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ imageSrc: {
|
|
|
+ type: String,
|
|
|
+ default: '',
|
|
|
+ },
|
|
|
+ backgroundColor: {
|
|
|
+ type: String,
|
|
|
+ default: 'transparent',
|
|
|
+ },
|
|
|
+ minWidth: {
|
|
|
+ type: Number,
|
|
|
+ default: 50,
|
|
|
+ },
|
|
|
+ minHeight: {
|
|
|
+ type: Number,
|
|
|
+ default: 50,
|
|
|
+ },
|
|
|
+ zoomStep: {
|
|
|
+ type: Number,
|
|
|
+ default: 0.1,
|
|
|
+ },
|
|
|
+ moveStep: {
|
|
|
+ type: Number,
|
|
|
+ default: 10,
|
|
|
+ },
|
|
|
+ rotateStep: {
|
|
|
+ type: Number,
|
|
|
+ default: 45,
|
|
|
+ },
|
|
|
+ previewQuality: {
|
|
|
+ type: Number,
|
|
|
+ default: 0.92,
|
|
|
+ validator: (value: number) => value >= 0 && value <= 1,
|
|
|
+ },
|
|
|
+ zoomSpeed: {
|
|
|
+ type: Number,
|
|
|
+ default: 0.002,
|
|
|
+ },
|
|
|
+});
|
|
|
+
|
|
|
+const emit = defineEmits(['success']);
|
|
|
+
|
|
|
+const imageElement = ref<HTMLImageElement | null>(null);
|
|
|
+const cropperWrapper = ref<HTMLElement | null>(null);
|
|
|
+const cropperArea = ref<HTMLElement | null>(null);
|
|
|
+const cropperFrame = ref<HTMLElement | null>(null);
|
|
|
+const fileRef = ref<HTMLElement | null>(null);
|
|
|
+
|
|
|
+const scale = ref<number>(1);
|
|
|
+const minScale = ref<number>(0.1);
|
|
|
+const maxScale = ref<number>(3);
|
|
|
+const rotation = ref<number>(0);
|
|
|
+const flipX = ref<number>(1); // 1: 正常, -1: 水平翻转
|
|
|
+const flipY = ref<number>(1); // 1: 正常, -1: 垂直翻转
|
|
|
+const position = ref<Position>({ x: 0, y: 0 });
|
|
|
+// const isDragging = ref<boolean>(false);
|
|
|
+const isResizing = ref<boolean>(false);
|
|
|
+const isFrameDragging = ref<boolean>(false);
|
|
|
+const startPos = ref<Position>({ x: 0, y: 0 });
|
|
|
+const frameSize = ref<Size>({ width: 200, height: 200 });
|
|
|
+const framePos = ref<FramePosition>({ left: 0, top: 0 });
|
|
|
+const resizeHandlePosition = ref<null | ResizeHandlePosition>(null);
|
|
|
+const startFrameSize = ref<Size>({ width: 0, height: 0 });
|
|
|
+const startFramePos = ref<FramePosition>({ left: 0, top: 0 });
|
|
|
+const aspectRatio = ref<number>(0);
|
|
|
+const previewImage = ref<string>('');
|
|
|
+const state = reactive({
|
|
|
+ sourceImage: '',
|
|
|
+ croppedImage: '',
|
|
|
+ fileName: '',
|
|
|
+});
|
|
|
+
|
|
|
+const aspectRatios = computed<AspectRatio[]>(() => {
|
|
|
+ return [
|
|
|
+ { value: 0, label: '自由' },
|
|
|
+ { value: 1, label: '1:1' },
|
|
|
+ { value: 16 / 9, label: '16:9' },
|
|
|
+ { value: 4 / 3, label: '4:3' },
|
|
|
+ { value: 3 / 4, label: '3:4' },
|
|
|
+ { value: 9 / 16, label: '9:16' },
|
|
|
+ ];
|
|
|
+});
|
|
|
+
|
|
|
+const resizeHandles = computed(() => {
|
|
|
+ return [
|
|
|
+ { position: 'top-left' },
|
|
|
+ { position: 'top-right' },
|
|
|
+ { position: 'bottom-left' },
|
|
|
+ { position: 'bottom-right' },
|
|
|
+ ];
|
|
|
+});
|
|
|
+
|
|
|
+const frameStyle = computed(() => {
|
|
|
+ return {
|
|
|
+ width: `${frameSize.value.width}px`,
|
|
|
+ height: `${frameSize.value.height}px`,
|
|
|
+ left: `${framePos.value.left}px`,
|
|
|
+ top: `${framePos.value.top}px`,
|
|
|
+ cursor: isFrameDragging.value ? 'grabbing' : 'move',
|
|
|
+ };
|
|
|
+});
|
|
|
+
|
|
|
+const imageStyle = computed(() => {
|
|
|
+ return {
|
|
|
+ transform: `scale(${scale.value * flipX.value}, ${scale.value * flipY.value}) rotate(${rotation.value}deg)`,
|
|
|
+ left: `${position.value.x}px`,
|
|
|
+ top: `${position.value.y}px`,
|
|
|
+ };
|
|
|
+});
|
|
|
+
|
|
|
+// 监听裁剪框变化,实时更新预览
|
|
|
+watch(
|
|
|
+ [frameSize, framePos, scale, rotation, flipX, flipY],
|
|
|
+ () => {
|
|
|
+ updatePreview();
|
|
|
+ },
|
|
|
+ { deep: true },
|
|
|
+);
|
|
|
+
|
|
|
+const initCropper = (): void => {
|
|
|
+ if (!imageElement.value || !cropperWrapper.value || !cropperFrame.value)
|
|
|
+ return;
|
|
|
+
|
|
|
+ const wrapperWidth = cropperWrapper.value.clientWidth;
|
|
|
+ const wrapperHeight = cropperWrapper.value.clientHeight;
|
|
|
+
|
|
|
+ // 根据宽高比设置初始裁剪框尺寸
|
|
|
+ let frameHeight: number, frameWidth: number;
|
|
|
+ if (aspectRatio.value > 0) {
|
|
|
+ frameWidth = Math.min(wrapperWidth * 0.6, 400);
|
|
|
+ frameHeight = frameWidth / aspectRatio.value;
|
|
|
+ } else {
|
|
|
+ frameWidth = Math.min(wrapperWidth * 0.6, 400);
|
|
|
+ frameHeight = Math.min(wrapperHeight * 0.6, 400);
|
|
|
+ }
|
|
|
+
|
|
|
+ frameSize.value = {
|
|
|
+ width: Math.max(frameWidth, props.minWidth),
|
|
|
+ height: Math.max(frameHeight, props.minHeight),
|
|
|
+ };
|
|
|
+
|
|
|
+ // 居中裁剪框
|
|
|
+ framePos.value = {
|
|
|
+ left: (wrapperWidth - frameSize.value.width) / 2,
|
|
|
+ top: (wrapperHeight - frameSize.value.height) / 2,
|
|
|
+ };
|
|
|
+
|
|
|
+ // 居中图片
|
|
|
+ centerImage();
|
|
|
+ updatePreview();
|
|
|
+};
|
|
|
+
|
|
|
+const centerImage = (): void => {
|
|
|
+ if (!imageElement.value || !cropperWrapper.value) return;
|
|
|
+
|
|
|
+ const imgWidth = imageElement.value.naturalWidth * scale.value;
|
|
|
+ const imgHeight = imageElement.value.naturalHeight * scale.value;
|
|
|
+
|
|
|
+ position.value = {
|
|
|
+ x: (cropperWrapper.value.clientWidth - imgWidth) / 2,
|
|
|
+ y: (cropperWrapper.value.clientHeight - imgHeight) / 2,
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+const updatePreview = (): void => {
|
|
|
+ if (!imageElement.value || !cropperFrame.value || !cropperWrapper.value) {
|
|
|
+ previewImage.value = '';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const canvas = document.createElement('canvas');
|
|
|
+ const ctx = canvas.getContext('2d');
|
|
|
+ if (!ctx) return;
|
|
|
+
|
|
|
+ // 计算裁剪区域相对于图片的位置
|
|
|
+ const frameRect = cropperFrame.value.getBoundingClientRect();
|
|
|
+ // const wrapperRect = cropperWrapper.value.getBoundingClientRect();
|
|
|
+ const imgRect = imageElement.value.getBoundingClientRect();
|
|
|
+
|
|
|
+ // 计算裁剪区域在图片上的位置和尺寸
|
|
|
+ const scaleRatio = imageElement.value.naturalWidth / imgRect.width;
|
|
|
+ const cropX = (frameRect.left - imgRect.left) * scaleRatio;
|
|
|
+ const cropY = (frameRect.top - imgRect.top) * scaleRatio;
|
|
|
+ const cropWidth = frameRect.width * scaleRatio;
|
|
|
+ const cropHeight = frameRect.height * scaleRatio;
|
|
|
+
|
|
|
+ // 设置画布大小为裁剪框大小
|
|
|
+ canvas.width = cropWidth;
|
|
|
+ canvas.height = cropHeight;
|
|
|
+
|
|
|
+ // 设置背景
|
|
|
+ if (props.backgroundColor === 'transparent') {
|
|
|
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
+ } else {
|
|
|
+ ctx.fillStyle = props.backgroundColor;
|
|
|
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 绘制裁剪后的图像
|
|
|
+ ctx.save();
|
|
|
+ ctx.translate(canvas.width / 2, canvas.height / 2);
|
|
|
+ ctx.rotate((rotation.value * Math.PI) / 180);
|
|
|
+ ctx.scale(flipX.value, flipY.value);
|
|
|
+ ctx.translate(-canvas.width / 2, -canvas.height / 2);
|
|
|
+ ctx.drawImage(
|
|
|
+ imageElement.value,
|
|
|
+ cropX,
|
|
|
+ cropY,
|
|
|
+ cropWidth,
|
|
|
+ cropHeight,
|
|
|
+ 0,
|
|
|
+ 0,
|
|
|
+ canvas.width,
|
|
|
+ canvas.height,
|
|
|
+ );
|
|
|
+ ctx.restore();
|
|
|
+
|
|
|
+ // 获取预览图像数据
|
|
|
+ previewImage.value = canvas.toDataURL('image/png', props.previewQuality);
|
|
|
+};
|
|
|
+
|
|
|
+const handleWheel = (e: WheelEvent): void => {
|
|
|
+ e.preventDefault();
|
|
|
+
|
|
|
+ // 计算缩放增量,考虑滚轮方向和速度
|
|
|
+ const delta = -e.deltaY * props.zoomSpeed;
|
|
|
+
|
|
|
+ // 应用缩放,限制在最小和最大值之间
|
|
|
+ const newScale = Math.max(
|
|
|
+ minScale.value,
|
|
|
+ Math.min(maxScale.value, scale.value + delta),
|
|
|
+ );
|
|
|
+
|
|
|
+ // 计算鼠标相对于图片的位置
|
|
|
+ if (imageElement.value && cropperArea.value) {
|
|
|
+ // const imgRect = imageElement.value.getBoundingClientRect();
|
|
|
+ const areaRect = cropperArea.value.getBoundingClientRect();
|
|
|
+
|
|
|
+ // 鼠标在图片上的相对位置 (0-1)
|
|
|
+ // const mouseX = (e.clientX - imgRect.left) / imgRect.width;
|
|
|
+ // const mouseY = (e.clientY - imgRect.top) / imgRect.height;
|
|
|
+
|
|
|
+ // 计算图片中心点到鼠标点的向量
|
|
|
+ const centerX = areaRect.width / 2;
|
|
|
+ const centerY = areaRect.height / 2;
|
|
|
+ const offsetX = position.value.x - centerX;
|
|
|
+ const offsetY = position.value.y - centerY;
|
|
|
+
|
|
|
+ // 调整位置,使鼠标点保持在同一位置
|
|
|
+ position.value = {
|
|
|
+ x: centerX + offsetX * (newScale / scale.value),
|
|
|
+ y: centerY + offsetY * (newScale / scale.value),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ scale.value = newScale;
|
|
|
+ updateTransform();
|
|
|
+};
|
|
|
+
|
|
|
+const zoomIn = (): void => {
|
|
|
+ scale.value = Math.min(maxScale.value, scale.value + props.zoomStep);
|
|
|
+ centerImage();
|
|
|
+};
|
|
|
+
|
|
|
+const zoomOut = (): void => {
|
|
|
+ scale.value = Math.max(minScale.value, scale.value - props.zoomStep);
|
|
|
+ centerImage();
|
|
|
+};
|
|
|
+
|
|
|
+const resetZoom = (): void => {
|
|
|
+ scale.value = 1;
|
|
|
+ centerImage();
|
|
|
+};
|
|
|
+
|
|
|
+const moveImage = (direction: MoveDirection): void => {
|
|
|
+ if (!imageElement.value || !cropperWrapper.value) return;
|
|
|
+
|
|
|
+ const moveStep = props.moveStep * scale.value;
|
|
|
+
|
|
|
+ switch (direction) {
|
|
|
+ case 'down': {
|
|
|
+ position.value.y += moveStep;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case 'left': {
|
|
|
+ position.value.x -= moveStep;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case 'right': {
|
|
|
+ position.value.x += moveStep;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case 'up': {
|
|
|
+ position.value.y -= moveStep;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // updateTransform();
|
|
|
+ updatePreview();
|
|
|
+};
|
|
|
+
|
|
|
+const rotateLeft = (): void => {
|
|
|
+ rotation.value = (rotation.value - props.rotateStep) % 360;
|
|
|
+ updateTransform();
|
|
|
+};
|
|
|
+
|
|
|
+const rotateRight = (): void => {
|
|
|
+ rotation.value = (rotation.value + props.rotateStep) % 360;
|
|
|
+ updateTransform();
|
|
|
+};
|
|
|
+
|
|
|
+const resetRotation = (): void => {
|
|
|
+ rotation.value = 0;
|
|
|
+ updateTransform();
|
|
|
+};
|
|
|
+
|
|
|
+const flipHorizontal = (): void => {
|
|
|
+ flipX.value *= -1;
|
|
|
+ updateTransform();
|
|
|
+};
|
|
|
+
|
|
|
+const flipVertical = (): void => {
|
|
|
+ flipY.value *= -1;
|
|
|
+ updateTransform();
|
|
|
+};
|
|
|
+
|
|
|
+const resetFlip = (): void => {
|
|
|
+ flipX.value = 1;
|
|
|
+ flipY.value = 1;
|
|
|
+ updateTransform();
|
|
|
+};
|
|
|
+
|
|
|
+const updateTransform = (): void => {
|
|
|
+ centerImage();
|
|
|
+};
|
|
|
+
|
|
|
+const startFrameDrag = (e: MouseEvent): void => {
|
|
|
+ if (e.target !== e.currentTarget) return; // 确保点击的是框架本身,而不是调整手柄
|
|
|
+
|
|
|
+ isFrameDragging.value = true;
|
|
|
+ startPos.value = {
|
|
|
+ x: e.clientX - framePos.value.left,
|
|
|
+ y: e.clientY - framePos.value.top,
|
|
|
+ };
|
|
|
+ document.addEventListener('mousemove', dragFrame);
|
|
|
+ document.addEventListener('mouseup', stopFrameDrag);
|
|
|
+};
|
|
|
+
|
|
|
+const dragFrame = (e: MouseEvent): void => {
|
|
|
+ if (!isFrameDragging.value || !cropperWrapper.value) return;
|
|
|
+
|
|
|
+ const wrapperWidth = cropperWrapper.value.clientWidth;
|
|
|
+ const wrapperHeight = cropperWrapper.value.clientHeight;
|
|
|
+
|
|
|
+ let newLeft = e.clientX - startPos.value.x;
|
|
|
+ let newTop = e.clientY - startPos.value.y;
|
|
|
+
|
|
|
+ // 确保裁剪框不会超出容器
|
|
|
+ newLeft = Math.max(
|
|
|
+ 0,
|
|
|
+ Math.min(wrapperWidth - frameSize.value.width, newLeft),
|
|
|
+ );
|
|
|
+ newTop = Math.max(
|
|
|
+ 0,
|
|
|
+ Math.min(wrapperHeight - frameSize.value.height, newTop),
|
|
|
+ );
|
|
|
+
|
|
|
+ framePos.value = {
|
|
|
+ left: newLeft,
|
|
|
+ top: newTop,
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+const stopFrameDrag = (): void => {
|
|
|
+ isFrameDragging.value = false;
|
|
|
+ document.removeEventListener('mousemove', dragFrame);
|
|
|
+ document.removeEventListener('mouseup', stopFrameDrag);
|
|
|
+};
|
|
|
+
|
|
|
+const startResize = (e: MouseEvent, position: ResizeHandlePosition): void => {
|
|
|
+ e.preventDefault();
|
|
|
+ e.stopPropagation();
|
|
|
+
|
|
|
+ isResizing.value = true;
|
|
|
+ resizeHandlePosition.value = position;
|
|
|
+ startPos.value = { x: e.clientX, y: e.clientY };
|
|
|
+ startFrameSize.value = { ...frameSize.value };
|
|
|
+ startFramePos.value = { ...framePos.value };
|
|
|
+
|
|
|
+ document.addEventListener('mousemove', handleResize);
|
|
|
+ document.addEventListener('mouseup', stopResize);
|
|
|
+};
|
|
|
+
|
|
|
+const handleResize = (e: MouseEvent): void => {
|
|
|
+ if (!isResizing.value || !resizeHandlePosition.value || !cropperWrapper.value)
|
|
|
+ return;
|
|
|
+
|
|
|
+ const deltaX = e.clientX - startPos.value.x;
|
|
|
+ const deltaY = e.clientY - startPos.value.y;
|
|
|
+
|
|
|
+ let newWidth = startFrameSize.value.width;
|
|
|
+ let newHeight = startFrameSize.value.height;
|
|
|
+ let newLeft = startFramePos.value.left;
|
|
|
+ let newTop = startFramePos.value.top;
|
|
|
+
|
|
|
+ const wrapperWidth = cropperWrapper.value.clientWidth;
|
|
|
+ const wrapperHeight = cropperWrapper.value.clientHeight;
|
|
|
+
|
|
|
+ switch (resizeHandlePosition.value) {
|
|
|
+ case 'bottom-left': {
|
|
|
+ newWidth = Math.max(props.minWidth, startFrameSize.value.width - deltaX);
|
|
|
+ newHeight =
|
|
|
+ aspectRatio.value > 0
|
|
|
+ ? newWidth / aspectRatio.value
|
|
|
+ : Math.max(props.minHeight, startFrameSize.value.height + deltaY);
|
|
|
+ newLeft = startFramePos.value.left + deltaX;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ case 'bottom-right': {
|
|
|
+ newWidth = Math.max(props.minWidth, startFrameSize.value.width + deltaX);
|
|
|
+ newHeight =
|
|
|
+ aspectRatio.value > 0
|
|
|
+ ? newWidth / aspectRatio.value
|
|
|
+ : Math.max(props.minHeight, startFrameSize.value.height + deltaY);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ case 'top-left': {
|
|
|
+ newWidth = Math.max(props.minWidth, startFrameSize.value.width - deltaX);
|
|
|
+ newHeight =
|
|
|
+ aspectRatio.value > 0
|
|
|
+ ? newWidth / aspectRatio.value
|
|
|
+ : Math.max(props.minHeight, startFrameSize.value.height - deltaY);
|
|
|
+ newLeft = startFramePos.value.left + deltaX;
|
|
|
+ newTop =
|
|
|
+ aspectRatio.value > 0
|
|
|
+ ? startFramePos.value.top + (startFrameSize.value.height - newHeight)
|
|
|
+ : startFramePos.value.top + deltaY;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ case 'top-right': {
|
|
|
+ newWidth = Math.max(props.minWidth, startFrameSize.value.width + deltaX);
|
|
|
+ newHeight =
|
|
|
+ aspectRatio.value > 0
|
|
|
+ ? newWidth / aspectRatio.value
|
|
|
+ : Math.max(props.minHeight, startFrameSize.value.height - deltaY);
|
|
|
+ newTop =
|
|
|
+ aspectRatio.value > 0
|
|
|
+ ? startFramePos.value.top + (startFrameSize.value.height - newHeight)
|
|
|
+ : startFramePos.value.top + deltaY;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 确保裁剪框不会超出容器
|
|
|
+ if (newLeft < 0) {
|
|
|
+ newWidth += newLeft;
|
|
|
+ newLeft = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (newTop < 0) {
|
|
|
+ newHeight += newTop;
|
|
|
+ newTop = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (newLeft + newWidth > wrapperWidth) {
|
|
|
+ newWidth = wrapperWidth - newLeft;
|
|
|
+ if (aspectRatio.value > 0) {
|
|
|
+ newHeight = newWidth / aspectRatio.value;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (newTop + newHeight > wrapperHeight) {
|
|
|
+ newHeight = wrapperHeight - newTop;
|
|
|
+ if (aspectRatio.value > 0) {
|
|
|
+ newWidth = newHeight * aspectRatio.value;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 应用新尺寸和位置
|
|
|
+ frameSize.value = {
|
|
|
+ width: Math.max(props.minWidth, newWidth),
|
|
|
+ height: Math.max(props.minHeight, newHeight),
|
|
|
+ };
|
|
|
+
|
|
|
+ framePos.value = {
|
|
|
+ left: newLeft,
|
|
|
+ top: newTop,
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+const stopResize = (): void => {
|
|
|
+ isResizing.value = false;
|
|
|
+ resizeHandlePosition.value = null;
|
|
|
+ document.removeEventListener('mousemove', handleResize);
|
|
|
+ document.removeEventListener('mouseup', stopResize);
|
|
|
+};
|
|
|
+
|
|
|
+const setAspectRatio = (ratio: number): void => {
|
|
|
+ aspectRatio.value = ratio;
|
|
|
+
|
|
|
+ if (ratio > 0 && cropperWrapper.value) {
|
|
|
+ // 保持裁剪框中心位置不变,只调整大小
|
|
|
+ const centerX = framePos.value.left + frameSize.value.width / 2;
|
|
|
+ const centerY = framePos.value.top + frameSize.value.height / 2;
|
|
|
+
|
|
|
+ const newWidth = Math.min(
|
|
|
+ frameSize.value.width,
|
|
|
+ cropperWrapper.value.clientWidth,
|
|
|
+ );
|
|
|
+ const newHeight = newWidth / ratio;
|
|
|
+
|
|
|
+ frameSize.value = {
|
|
|
+ width: newWidth,
|
|
|
+ height: newHeight,
|
|
|
+ };
|
|
|
+
|
|
|
+ framePos.value = {
|
|
|
+ left: centerX - newWidth / 2,
|
|
|
+ top: centerY - newHeight / 2,
|
|
|
+ };
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const handleCrop = async () => {
|
|
|
+ if (!previewImage.value) {
|
|
|
+ updatePreview();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ lock();
|
|
|
+ const data = await FileApi.uploadBase64({
|
|
|
+ fileName: state.fileName,
|
|
|
+ base64: previewImage.value,
|
|
|
+ path: '',
|
|
|
+ directory: '',
|
|
|
+ allowSuffix: '',
|
|
|
+ folderId: 0,
|
|
|
+ });
|
|
|
+ emit('success', data);
|
|
|
+ close();
|
|
|
+ } finally {
|
|
|
+ unlock();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ if (imageElement.value?.complete) {
|
|
|
+ initCropper();
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ document.removeEventListener('mousemove', dragFrame);
|
|
|
+ document.removeEventListener('mouseup', stopFrameDrag);
|
|
|
+ document.removeEventListener('mousemove', handleResize);
|
|
|
+ document.removeEventListener('mouseup', stopResize);
|
|
|
+});
|
|
|
+
|
|
|
+const handleUpload = () => {
|
|
|
+ fileRef.value?.click();
|
|
|
+};
|
|
|
+
|
|
|
+const reset = () => {
|
|
|
+ state.croppedImage = state.sourceImage;
|
|
|
+ flipX.value = 1;
|
|
|
+ flipY.value = 1;
|
|
|
+ rotation.value = 0;
|
|
|
+ scale.value = 1;
|
|
|
+ updateTransform();
|
|
|
+};
|
|
|
+
|
|
|
+const compProps = reactive({
|
|
|
+ beforeChange: undefined,
|
|
|
+ disabled: false,
|
|
|
+ gap: 0,
|
|
|
+ showIcon: false,
|
|
|
+ // size: 'large',
|
|
|
+ allowClear: false,
|
|
|
+} as Recordable<any>);
|
|
|
+
|
|
|
+const groupButton = reactive({
|
|
|
+ groups: [
|
|
|
+ [
|
|
|
+ { fn: zoomIn, tooltip: '放大', icon: 'si:add-fill' },
|
|
|
+ { fn: zoomOut, tooltip: '缩小', icon: 'si:remove-fill' },
|
|
|
+ { fn: resetZoom, tooltip: '重置缩放', icon: 'ri:reset-left-fill' },
|
|
|
+ ],
|
|
|
+ [
|
|
|
+ { fn: rotateLeft, tooltip: '向左旋转', icon: 'gridicons:undo' },
|
|
|
+ { fn: rotateRight, tooltip: '向右旋转', icon: 'gridicons:redo' },
|
|
|
+ { fn: resetRotation, tooltip: '重置旋转', icon: 'ri:reset-left-fill' },
|
|
|
+ ],
|
|
|
+ [
|
|
|
+ {
|
|
|
+ fn: () => moveImage('left'),
|
|
|
+ tooltip: '左移',
|
|
|
+ icon: 'gridicons:chevron-left',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ fn: () => moveImage('right'),
|
|
|
+ tooltip: '右移',
|
|
|
+ icon: 'gridicons:chevron-right',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ fn: () => moveImage('up'),
|
|
|
+ tooltip: '上移',
|
|
|
+ icon: 'gridicons:chevron-up',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ fn: () => moveImage('down'),
|
|
|
+ tooltip: '下移',
|
|
|
+ icon: 'gridicons:chevron-down',
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ [
|
|
|
+ {
|
|
|
+ fn: flipHorizontal,
|
|
|
+ tooltip: '水平翻转',
|
|
|
+ icon: 'solar:sort-horizontal-bold',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ fn: flipVertical,
|
|
|
+ tooltip: '垂直翻转',
|
|
|
+ icon: 'solar:sort-vertical-bold',
|
|
|
+ },
|
|
|
+ { fn: resetFlip, tooltip: '重置翻转', icon: 'ri:reset-left-fill' },
|
|
|
+ ],
|
|
|
+ [
|
|
|
+ {
|
|
|
+ fn: handleUpload,
|
|
|
+ tooltip: '选择图片',
|
|
|
+ icon: 'icon-park-outline:upload-one',
|
|
|
+ },
|
|
|
+ { fn: reset, tooltip: '重新开始', icon: 'ri:reset-left-fill' },
|
|
|
+ ],
|
|
|
+ ],
|
|
|
+ size: '48',
|
|
|
+});
|
|
|
+
|
|
|
+const handleFileChange = (e: Event): void => {
|
|
|
+ const input = e.target as HTMLInputElement;
|
|
|
+ const file = input.files?.[0];
|
|
|
+ if (!file) return;
|
|
|
+ state.fileName = file.name;
|
|
|
+ const reader = new FileReader();
|
|
|
+ reader.addEventListener('load', (event) => {
|
|
|
+ state.sourceImage = event.target?.result as string;
|
|
|
+ reset();
|
|
|
+ });
|
|
|
+ reader.readAsDataURL(file);
|
|
|
+};
|
|
|
+
|
|
|
+const [Modal, { close, setState, getData, lock, unlock }] = useVbenModal({
|
|
|
+ fullscreenButton: false,
|
|
|
+ draggable: true,
|
|
|
+ closeOnClickModal: false,
|
|
|
+ footer: false,
|
|
|
+ onBeforeClose() {
|
|
|
+ state.sourceImage = '';
|
|
|
+ previewImage.value = '';
|
|
|
+ reset();
|
|
|
+ return true;
|
|
|
+ },
|
|
|
+ onCancel() {
|
|
|
+ close();
|
|
|
+ },
|
|
|
+ onConfirm: async () => {
|
|
|
+ try {
|
|
|
+ lock();
|
|
|
+
|
|
|
+ emit('success');
|
|
|
+ close();
|
|
|
+ } finally {
|
|
|
+ unlock();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onOpenChange: async (isOpen: boolean) => {
|
|
|
+ if (isOpen) {
|
|
|
+ setState({ loading: true });
|
|
|
+ const data = getData<Record<string, any>>();
|
|
|
+ if (data.baseData?.url) {
|
|
|
+ try {
|
|
|
+ const base64 = await FileApi.getBase64Url(data.baseData.url);
|
|
|
+ state.sourceImage = `data:image/png;base64,${base64}`;
|
|
|
+ reset();
|
|
|
+ } catch {}
|
|
|
+ }
|
|
|
+ setState({ loading: false });
|
|
|
+ }
|
|
|
+ },
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <Modal class="w-[1000px]" title="裁剪图片">
|
|
|
+ <div class="image-cropper-container">
|
|
|
+ <img style="display: none" :src="state.sourceImage" />
|
|
|
+ <div class="flex">
|
|
|
+ <div class="cropper-wrapper" ref="cropperWrapper">
|
|
|
+ <div
|
|
|
+ class="cropper-area"
|
|
|
+ ref="cropperArea"
|
|
|
+ @wheel.prevent="handleWheel"
|
|
|
+ v-if="state.croppedImage"
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ ref="imageElement"
|
|
|
+ :src="state.croppedImage"
|
|
|
+ @load="initCropper"
|
|
|
+ alt="Image to crop"
|
|
|
+ class="cropper-image"
|
|
|
+ :style="imageStyle"
|
|
|
+ />
|
|
|
+ <div class="cropper-overlay">
|
|
|
+ <div
|
|
|
+ class="cropper-frame"
|
|
|
+ ref="cropperFrame"
|
|
|
+ :style="frameStyle"
|
|
|
+ @mousedown="startFrameDrag"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ v-for="handle in resizeHandles"
|
|
|
+ :key="handle.position"
|
|
|
+ class="resize-handle"
|
|
|
+ :class="`resize-handle-${handle.position}`"
|
|
|
+ @mousedown.stop="
|
|
|
+ startResize($event, handle.position as ResizeHandlePosition)
|
|
|
+ "
|
|
|
+ ></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="flex-1">
|
|
|
+ <input
|
|
|
+ type="file"
|
|
|
+ style="display: none"
|
|
|
+ ref="fileRef"
|
|
|
+ @change="handleFileChange"
|
|
|
+ />
|
|
|
+ <div class="ml-2">
|
|
|
+ <div>
|
|
|
+ <div class="preview-image-wrapper">
|
|
|
+ <div class="mr-2 h-[144px]">
|
|
|
+ <Image
|
|
|
+ v-if="previewImage"
|
|
|
+ height="100%"
|
|
|
+ :src="previewImage"
|
|
|
+ :preview="false"
|
|
|
+ style="object-fit: cover"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="mr-2 h-[72px]">
|
|
|
+ <Image
|
|
|
+ v-if="previewImage"
|
|
|
+ height="100%"
|
|
|
+ :src="previewImage"
|
|
|
+ :preview="false"
|
|
|
+ style="object-fit: cover"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="mr-2 h-[36px]">
|
|
|
+ <Image
|
|
|
+ v-if="previewImage"
|
|
|
+ height="100%"
|
|
|
+ :src="previewImage"
|
|
|
+ :preview="false"
|
|
|
+ style="object-fit: cover"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="h-[18px]">
|
|
|
+ <Image
|
|
|
+ v-if="previewImage"
|
|
|
+ height="100%"
|
|
|
+ :src="previewImage"
|
|
|
+ :preview="false"
|
|
|
+ style="object-fit: cover"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="mt-2">
|
|
|
+ <div class="flex items-center">
|
|
|
+ <div class="w-[70px]">X轴坐标</div>
|
|
|
+ <div class="ml-2 mr-2 flex-1">
|
|
|
+ <Input v-model:value="framePos.left" suffix="像素" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="mt-2 flex items-center">
|
|
|
+ <div class="w-[70px]">Y轴坐标</div>
|
|
|
+ <div class="ml-2 mr-2 flex-1">
|
|
|
+ <Input v-model:value="framePos.top" suffix="像素" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="mt-2 flex items-center">
|
|
|
+ <div class="w-[70px]">宽度</div>
|
|
|
+ <div class="ml-2 mr-2 flex-1">
|
|
|
+ <Input v-model:value="frameSize.width" suffix="像素" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="mt-2 flex items-center">
|
|
|
+ <div class="w-[70px]">高度</div>
|
|
|
+ <div class="ml-2 mr-2 flex-1">
|
|
|
+ <Input v-model:value="frameSize.height" suffix="像素" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="mt-2 flex items-center">
|
|
|
+ <div class="w-[70px]">旋转</div>
|
|
|
+ <div class="ml-2 mr-2 flex-1">
|
|
|
+ <Input v-model:value="rotation" suffix="度" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="mt-2 flex items-center">
|
|
|
+ <div class="w-[70px]">左右翻转</div>
|
|
|
+ <div class="ml-2 mr-2 flex-1">
|
|
|
+ <Input v-model:value="flipX" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="mt-2 flex items-center">
|
|
|
+ <div class="w-[70px]">上下翻转</div>
|
|
|
+ <div class="ml-2 mr-2 flex-1">
|
|
|
+ <Input v-model:value="flipY" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <VbenButtonGroup v-bind="compProps" border class="mt-2">
|
|
|
+ <VbenButton
|
|
|
+ variant="default"
|
|
|
+ @click="setAspectRatio(ratio.value)"
|
|
|
+ v-for="ratio in aspectRatios"
|
|
|
+ :key="ratio.value"
|
|
|
+ >
|
|
|
+ {{ ratio.label }}
|
|
|
+ </VbenButton>
|
|
|
+ </VbenButtonGroup>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="cropper-controls mt-4 flex">
|
|
|
+ <VbenButtonGroup
|
|
|
+ v-bind="compProps"
|
|
|
+ border
|
|
|
+ v-for="(group, index) in groupButton.groups"
|
|
|
+ :key="index"
|
|
|
+ :class="index !== 0 ? 'ml-4' : ''"
|
|
|
+ >
|
|
|
+ <VbenIconButton
|
|
|
+ variant="default"
|
|
|
+ @click="but.fn"
|
|
|
+ :tooltip="but.tooltip"
|
|
|
+ v-for="but in group"
|
|
|
+ :key="but.tooltip"
|
|
|
+ >
|
|
|
+ <Icon :icon="but.icon" :size="groupButton.size" />
|
|
|
+ </VbenIconButton>
|
|
|
+ </VbenButtonGroup>
|
|
|
+
|
|
|
+ <VbenButton variant="default" @click="handleCrop" class="ml-4">
|
|
|
+ 完成
|
|
|
+ </VbenButton>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Modal>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.image-cropper-container {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ width: 100%;
|
|
|
+ max-width: 1000px;
|
|
|
+ height: 100%;
|
|
|
+ margin: 0 auto;
|
|
|
+}
|
|
|
+
|
|
|
+.cropper-wrapper {
|
|
|
+ position: relative;
|
|
|
+ width: 500px;
|
|
|
+ height: 500px;
|
|
|
+ overflow: hidden;
|
|
|
+ background-color: #f0f0f0;
|
|
|
+ background-image:
|
|
|
+ linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
|
|
|
+ linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
|
|
|
+ linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
|
|
|
+ linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
|
|
|
+ background-position:
|
|
|
+ 0 0,
|
|
|
+ 0 10px,
|
|
|
+ 10px -10px,
|
|
|
+ -10px 0;
|
|
|
+ background-size: 20px 20px;
|
|
|
+
|
|
|
+ /* border: 1px solid #ddd; */
|
|
|
+}
|
|
|
+
|
|
|
+.cropper-area {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ overflow: hidden;
|
|
|
+ cursor: move;
|
|
|
+}
|
|
|
+
|
|
|
+.cropper-image {
|
|
|
+ position: absolute;
|
|
|
+ max-width: none;
|
|
|
+ transform-origin: center center;
|
|
|
+}
|
|
|
+
|
|
|
+.cropper-overlay {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ background-color: rgb(0 0 0 / 50%);
|
|
|
+}
|
|
|
+
|
|
|
+.cropper-frame {
|
|
|
+ position: absolute;
|
|
|
+ z-index: 10;
|
|
|
+ border: 2px dashed #fff;
|
|
|
+ box-shadow: 0 0 0 9999px rgb(0 0 0 / 50%);
|
|
|
+}
|
|
|
+
|
|
|
+.resize-handle {
|
|
|
+ position: absolute;
|
|
|
+ z-index: 11;
|
|
|
+ width: 12px;
|
|
|
+ height: 12px;
|
|
|
+ background-color: #fff;
|
|
|
+ border: 2px solid #2196f3;
|
|
|
+ border-radius: 50%;
|
|
|
+}
|
|
|
+
|
|
|
+.resize-handle-top-left {
|
|
|
+ top: -6px;
|
|
|
+ left: -6px;
|
|
|
+ cursor: nwse-resize;
|
|
|
+}
|
|
|
+
|
|
|
+.resize-handle-top-right {
|
|
|
+ top: -6px;
|
|
|
+ right: -6px;
|
|
|
+ cursor: nesw-resize;
|
|
|
+}
|
|
|
+
|
|
|
+.resize-handle-bottom-left {
|
|
|
+ bottom: -6px;
|
|
|
+ left: -6px;
|
|
|
+ cursor: nesw-resize;
|
|
|
+}
|
|
|
+
|
|
|
+.resize-handle-bottom-right {
|
|
|
+ right: -6px;
|
|
|
+ bottom: -6px;
|
|
|
+ cursor: nwse-resize;
|
|
|
+}
|
|
|
+
|
|
|
+.cropper-controls {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.control-group {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 200px;
|
|
|
+}
|
|
|
+
|
|
|
+.control-group h3 {
|
|
|
+ margin-bottom: 10px;
|
|
|
+ font-size: 16px;
|
|
|
+ color: #333;
|
|
|
+}
|
|
|
+
|
|
|
+.zoom-controls {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.zoom-info {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #555;
|
|
|
+}
|
|
|
+
|
|
|
+.zoom-hint {
|
|
|
+ margin-left: 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #888;
|
|
|
+}
|
|
|
+
|
|
|
+.button-group {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.move-controls {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 5px;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.move-horizontal {
|
|
|
+ display: flex;
|
|
|
+ gap: 5px;
|
|
|
+}
|
|
|
+
|
|
|
+.move-btn {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 36px;
|
|
|
+ height: 36px;
|
|
|
+ cursor: pointer;
|
|
|
+ background-color: #f5f5f5;
|
|
|
+ border: 1px solid #ddd;
|
|
|
+ border-radius: 4px;
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.move-btn:hover {
|
|
|
+ background-color: #e0e0e0;
|
|
|
+}
|
|
|
+
|
|
|
+.move-btn svg {
|
|
|
+ fill: #333;
|
|
|
+}
|
|
|
+
|
|
|
+.control-btn {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 36px;
|
|
|
+ height: 36px;
|
|
|
+ cursor: pointer;
|
|
|
+ background-color: #f5f5f5;
|
|
|
+ border: 1px solid #ddd;
|
|
|
+ border-radius: 4px;
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.control-btn:hover {
|
|
|
+ background-color: #e0e0e0;
|
|
|
+}
|
|
|
+
|
|
|
+.control-btn svg {
|
|
|
+ fill: #333;
|
|
|
+}
|
|
|
+
|
|
|
+.ratio-btn {
|
|
|
+ padding: 6px 12px;
|
|
|
+ cursor: pointer;
|
|
|
+ background-color: #f5f5f5;
|
|
|
+ border: 1px solid #ddd;
|
|
|
+ border-radius: 4px;
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.ratio-btn:hover {
|
|
|
+ background-color: #e0e0e0;
|
|
|
+}
|
|
|
+
|
|
|
+.ratio-btn.active {
|
|
|
+ color: white;
|
|
|
+ background-color: #2196f3;
|
|
|
+ border-color: #2196f3;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-container {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-image-wrapper {
|
|
|
+ display: flex;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.action-buttons {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ margin-top: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-primary {
|
|
|
+ padding: 10px 20px;
|
|
|
+ color: white;
|
|
|
+ cursor: pointer;
|
|
|
+ background-color: #2196f3;
|
|
|
+ border: none;
|
|
|
+ border-radius: 4px;
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-primary:hover {
|
|
|
+ background-color: #0b7dda;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-confirm {
|
|
|
+ padding: 10px 20px;
|
|
|
+ color: white;
|
|
|
+ cursor: pointer;
|
|
|
+ background-color: #4caf50;
|
|
|
+ border: none;
|
|
|
+ border-radius: 4px;
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-confirm:hover {
|
|
|
+ background-color: #388e3c;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-secondary {
|
|
|
+ padding: 10px 20px;
|
|
|
+ color: white;
|
|
|
+ cursor: pointer;
|
|
|
+ background-color: #f44336;
|
|
|
+ border: none;
|
|
|
+ border-radius: 4px;
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-secondary:hover {
|
|
|
+ background-color: #d32f2f;
|
|
|
+}
|
|
|
+</style>
|