uniapp 设计示例demo

196 阅读9分钟

场景和功能

有一个用户自定义衣服、手机壳、书包等等东西的图案logo,如下面这个小程序的业务

image.png

这个小程序就是可以定制衣服上的logo然后下单制作你自定义图案的衣服

核心就是有一个自定义的定制区域,在区域内可以自己设计图片和文本

目前demo实现的功能:

  • 可添加文本和图片素材
  • 素材支持:单指移动、两指缩放和旋转、使用操作按钮使素材:旋转、缩放、删除、编辑
  • 图片素材可支持内置图片和用户上传的自定义图片
  • 文本素材支持大多数文本的样式:颜色、字号、粗细、倾斜、下划线、删除线、行高、间距、对齐方式等等

效果如下图

image.png

代码

<template>
	<div class="page-box">
		<!-- 编辑区域 -->
		<div
			class="design-box"
			@click="
				addPopup = false;
				currentIndex = -1;
			"
		>
			<div
				class="design-box-item"
				v-for="(item, index) in list"
				:key="index"
				:style="[
					{
						left: item.positionX + 'px',
						top: item.positionY + 'px',
						transform: `rotate(${item.rotate || 0}deg) scale(${item.scale || 1})`,
					},
				]"
				@click.stop="currentIndex = index"
			>
				<text
					v-if="item.type == 'text'"
					:style="[
						item.style,
						{
							display: 'block',
							'white-space': 'pre-wrap',
							'word-wrap': 'break-word',
							'word-break': 'break-all',
							'unicode-bidi': 'bidi-override',
							fontSize: item.style.fontSize + 'px',
							'letter-spacing': item.style.letterSpacing + 'px',
							width: 'fit-content',
							transform: item.style.transform,
						},
					]"
				>
					{{ item.text }}
				</text>
				<image
					v-else
					:src="item.url"
					mode="scaleToFill"
					:style="[
						item.style,
						{
							width: item.style.width + 'px',
							height: item.style.height + 'px',
						},
					]"
				/>
			</div>
		</div>
		<!-- 素材操作元素 -->
		<div
			class="controls-box"
			:style="[
				{
					width: controlInfo.width + 'px',
					height: controlInfo.height + 'px',
					top: controlInfo.top + 'px',
					left: controlInfo.left + 'px',
				},
			]"
			v-if="currentIndex > -1 && controlInfo"
			@touchstart="handleControlsTouchStart($event, currentIndex)"
			@touchmove="handleControlsTouchMove($event)"
			@touchend="handleControlsTouchEnd"
		>
			<div
				class="controls-scale"
				@touchstart.stop="handleScaleStart"
				@touchmove.stop="handleScaleMove"
				@touchend.stop="handleScaleEnd"
				>缩</div
			>
			<div class="controls-edit" @click.stop="handleEdit">编</div>
			<div class="controls-delete" @click.stop="handleDelete">删</div>
			<div
				class="controls-rotate"
				@touchstart.stop="handleRotateStart"
				@touchmove.stop="handleRotateMove"
				@touchend.stop="handleRotateEnd"
				>旋</div
			>
		</div>
		<!-- 添加素材 -->
		<div class="operate-box" v-if="!addPopup">
			<div class="operate-btn operate-text" @click="addMaterial('text')">添加文本素材</div>
			<div class="operate-btn operate-image" @click="addMaterial('image')">添加图片素材</div>
		</div>
	</div>

	<!-- 添加/编辑 素材弹窗 -->
	<my-popup v-model:show="addPopup" position="bottom" radius="10" :isMask="false">
		<div class="add-popup-box" v-if="currentIndex > -1">
			<div class="title">添加{{ addType == 'text' ? '文本' : '图片' }}素材</div>
			<button @click="addPopup = false">关闭</button>
			<div class="add-text-box" v-if="addType == 'text'">
				<div class="text-textarea">
					<textarea v-model="list[currentIndex].text" placeholder="请输入文本" auto-height />
				</div>
				<div class="text-operate-box">
					<div
						class="text-operate-item"
						v-for="(item, index) in textOperateList"
						:key="index"
						@click="handleTextOperate(item)"
					>
						{{ item.name }}
					</div>
				</div>
				<div class="text-color-box">
					<div
						class="text-color-item"
						v-for="(item, index) in textColorList"
						:key="index"
						:style="{
							backgroundColor: item,
							border: item == '#fff' ? '1rpx solid #000' : 'none',
						}"
						@click="handleTextColor(item)"
					>
					</div>
				</div>
				<div class="text-font-box">
					<div
						class="text-font-item"
						v-for="(item, index) in textFontList"
						:key="index"
						:style="{
							fontFamily: item,
						}"
						@click="handleTextFont(item)"
					>
						{{ item }}
					</div>
				</div>
			</div>
			<div class="add-image-box" v-else>
				<div
					class="add-image-item"
					v-for="(item, index) in imageList"
					:key="index"
					@click="handleAddImage(item)"
				>
					<image
						:src="item.url"
						mode="scaleToFill"
						:style="{ width: item.width + 'px', height: item.height + 'px' }"
					></image>
				</div>
				<div class="cursor-pointer" @click="customAddImage('custom')"> 自选图片</div>
			</div>
		</div>
	</my-popup>
</template>

<script setup>
	import { ref, onMounted, computed, watchEffect, getCurrentInstance, watch, nextTick } from 'vue';
	import { onLoad } from '@dcloudio/uni-app';
	const { proxy } = getCurrentInstance();
	onMounted(() => {
		getOperateBoxInfo();
	});
	onLoad((res) => {});

	// 获取编辑区域的信息:宽高/中心点坐标
	let designBoxInfo = {};
	function getOperateBoxInfo() {
		const query = uni.createSelectorQuery().in(proxy);
		query
			.select('.design-box')
			.boundingClientRect((data) => {
				data.centerX = data.width / 2;
				data.centerY = data.height / 2;
				designBoxInfo = data;
			})
			.exec();
	}

	// 素材列表
	const list = ref([]);
	// 当前操作的素材索引
	const currentIndex = ref(-1);

	import myPopup from './myPopup.vue';
	const addPopup = ref(false);
	watch(addPopup, (newVal) => {
		if (!newVal) {
			list.value = list.value.filter((item) => {
				if (item.type === 'image') {
					return item.url !== '';
				}
				return item.text !== '';
			});
			if (list.value.length === 0) {
				currentIndex.value = -1;
			} else {
				currentIndex.value = list.value.length - 1;
			}
		}
	});
	// 添加素材类型
	const addType = ref('');
	// 添加素材
	function addMaterial(type) {
		addType.value = type;
		addPopup.value = true;
		if (type == 'text') {
			addText();
		} else {
			addImage();
		}
	}

	// 添加文本素材
	function addText() {
		currentIndex.value = list.value.length;
		textOperateList.value[0].value = false;
		textOperateList.value[1].value = false;
		textOperateList.value[2].value = false;
		textOperateList.value[3].value = false;
		let data = {};
		data.type = 'text';
		data.text = '默认文本';
		data.positionX = designBoxInfo.centerX;
		data.positionY = designBoxInfo.centerY;
		data.style = {
			// 加粗
			fontWeight: 'normal',
			// 斜体
			fontStyle: 'normal',
			// 下划线 删除线
			textDecoration: 'none',
			// 字体大小
			fontSize: 16,
			// 字体颜色
			color: '#000',
			// 字体
			fontFamily: 'Arial',
			// 文本对齐方式
			textAlign: 'left',
			// 行高
			lineHeight: 1.2,
			// 文本间距
			letterSpacing: 0,
			// 文本水平方向
			direction: 'ltr',
			// 文本垂直方向
			writingMode: '',
			// 层级
			zIndex: list.value.length ? list.value[list.value.length - 1].style.zIndex + 1 : 1,
			transform: '',
		};
		data.rotate = 0;
		data.scale = 1;
		list.value.push(data);
	}
	// 文本操作列表
	const textOperateList = ref([
		{
			name: '是否加粗',
			key: 'is-weight',
			value: false,
		},
		{
			name: '是否斜体',
			key: 'is-italic',
			value: false,
		},
		{
			name: '是否下划线',
			key: 'is-underline',
			value: false,
		},
		{
			name: '是否删除线',
			key: 'is-line-through',
			value: false,
		},
		{
			name: '字体大小增加',
			key: 'font-size-add',
			value: 2,
		},
		{
			name: '字体大小减少',
			key: 'font-size-reduce',
			value: 2,
		},
		{
			name: '文本左对齐',
			key: 'text-align-left',
			value: 'left',
		},
		{
			name: '文本右对齐',
			key: 'text-align-right',
			value: 'right',
		},
		{
			name: '文本居中对齐',
			key: 'text-align-center',
			value: 'center',
		},
		{
			name: '文本行高增加',
			value: 0.2,
			key: 'text-line-height-add',
		},
		{
			name: '文本行高减少',
			value: 0.2,
			key: 'text-line-height-reduce',
		},
		{
			name: '文本间距增加',
			value: 2,
			key: 'text-spacing-add',
		},
		{
			name: '文本间距减少',
			value: 2,
			key: 'text-spacing-reduce',
		},
		{
			name: '文本方向:水平左到右',
			value: 'ltr',
			key: 'text-direction-left-to-right',
		},
		{
			name: '文本方向:水平右到左',
			value: 'rtl',
			key: 'text-direction-right-to-left',
		},
		{
			name: '文本方向:垂直左到右',
			value: 'vertical-lr',
			key: 'text-writingMode-top-to-bottom',
		},
		{
			name: '文本方向:垂直右到左',
			value: 'vertical-rl',
			key: 'text-writingMode-bottom-to-top',
		},
		{
			name: '水平翻转',
			key: 'text-flip-horizontal',
			value: false,
		},
		{
			name: '垂直翻转',
			key: 'text-flip-vertical',
			value: false,
		},
	]);
	// 文本操作
	function handleTextOperate(item) {
		let currentItem = list.value[currentIndex.value];
		switch (item.key) {
			case 'is-weight':
				item.value = !item.value;
				currentItem.style.fontWeight = item.value ? 'bold' : 'normal';
				break;
			case 'is-italic':
				item.value = !item.value;
				currentItem.style.fontStyle = item.value ? 'italic' : 'normal';
				break;
			case 'is-underline':
				item.value = !item.value;
				toggleTextDecoration(currentItem, 'underline', item.value);
				break;
			case 'is-line-through':
				item.value = !item.value;
				toggleTextDecoration(currentItem, 'line-through', item.value);
				break;
			case 'font-size-add':
				currentItem.style.fontSize += item.value;
				break;
			case 'font-size-reduce':
				currentItem.style.fontSize -= item.value;
				break;
			case 'text-align-left':
			case 'text-align-right':
			case 'text-align-center':
				currentItem.style.textAlign = item.value;
				break;
			case 'text-line-height-add':
				currentItem.style.lineHeight += item.value;
				break;
			case 'text-line-height-reduce':
				currentItem.style.lineHeight -= item.value;
				break;
			case 'text-spacing-add':
				currentItem.style.letterSpacing += item.value;
				break;
			case 'text-spacing-reduce':
				currentItem.style.letterSpacing -= item.value;
				break;
			case 'text-direction-left-to-right':
			case 'text-direction-right-to-left':
				currentItem.style.direction = item.value;
				// 清除垂直方向设置
				currentItem.style.writingMode = '';
				break;
			case 'text-writingMode-top-to-bottom':
			case 'text-writingMode-bottom-to-top':
				currentItem.style.writingMode = item.value;
				// 清除水平方向设置
				currentItem.style.direction = 'ltr';
				break;
			case 'text-flip-horizontal':
				item.value = !item.value;
				updateTransform(currentItem, 'rotateX', item.value ? '180deg' : '0deg');
				break;
			case 'text-flip-vertical':
				item.value = !item.value;
				updateTransform(currentItem, 'rotateY', item.value ? '180deg' : '0deg');
				break;
		}
		function toggleTextDecoration(element, style, isActive) {
			let decorations = element.style.textDecoration.split(' ').filter((d) => d && d !== 'none');

			if (isActive) {
				if (!decorations.includes(style)) {
					decorations.push(style);
				}
			} else {
				decorations = decorations.filter((d) => d !== style);
			}

			element.style.textDecoration = decorations.length ? decorations.join(' ') : 'none';
		}
		function updateTransform(element, property, value) {
			let transforms = element.style.transform ? element.style.transform.split(' ') : [];
			let hasProperty = false;

			transforms = transforms.map((t) => {
				if (t.includes(property)) {
					hasProperty = true;
					return `${property}(${value})`;
				}
				return t;
			});

			if (!hasProperty) {
				transforms.push(`${property}(${value})`);
			}

			element.style.transform = transforms.join(' ');
		}
	}
	// 文本颜色列表(测试用,正式使用时请通过接口获取)
	const textColorList = ref([
		'#000',
		'#fff',
		'#ccc',
		'#f00',
		'#0f0',
		'#00f',
		'#f0f',
		'#0ff',
		'#ff0',
	]);
	// 改变文本颜色
	function handleTextColor(item) {
		list.value[currentIndex.value].style.color = item;
	}
	// 文本字体列表(测试用,正式使用时请通过接口获取)
	const textFontList = ref([
		'Arial',
		'Helvetica',
		'Times New Roman',
		'Georgia',
		'Garamond',
		'Courier New',
		'Verdana',
		'Tahoma',
		'Trebuchet MS',
		'Source Han Sans SC',
	]);
	// 改变文本字体
	function handleTextFont(item) {
		list.value[currentIndex.value].style.fontFamily = item;
	}

	// 添加图片素材
	function addImage() {
		currentIndex.value = list.value.length;
		let data = {};
		data.type = 'image';
		data.url = '';
		data.positionX = designBoxInfo.centerX;
		data.positionY = designBoxInfo.centerY;
		data.style = {
			width: 50,
			height: 50,
			zIndex: list.value.length ? list.value[list.value.length - 1].style.zIndex + 1 : 1,
		};
		data.rotate = 0;
		data.scale = 1;
		list.value.push(data);
	}
	// 图片列表(测试用,正式使用时请通过接口获取)
	const imageList = ref([
		{
			name: '图片1',
			url: 'https://pic.20988.xyz/2024-08-29/1724898997-618191-preview.jpg',
		},
		{
			name: '图片2',
			url: 'https://pic.20988.xyz/2024-08-29/1724899028-533968-preview.jpg',
		},
		{
			name: '图片3',
			url: 'https://pic.20988.xyz/2024-08-29/1724899165-67654-preview.jpg',
		},
	]);
	// 给图片素材添加图片
	function handleAddImage(item) {
		list.value[currentIndex.value].url = item.url;
	}
	// 给图片素材添加自选图片
	function customAddImage() {
		const platform = uni.getSystemInfoSync().uniPlatform;
		switch (platform) {
			case 'web':
				uni.chooseImage({
					count: 1,
					success: (res) => {
						let arr = res.tempFiles.map((item) => {
							return {
								type: 'image',
								url: item.path,
								size: item.size,
								name: item.name || '',
							};
						});
						// 正式使用时解开
						// uploadFile(arr[0]);
						// 测试用的
						list.value[currentIndex.value].url = arr[0].url;
					},
					fail: () => {},
				});
				break;
			case 'mp-weixin':
				uni.chooseImage({
					count: 1,
					success: (res) => {
						let arr = res.tempFiles.map((item) => {
							return {
								type: 'image',
								url: item.path,
								size: item.size,
								name: item.name || '',
							};
						});
						// 正式使用时解开
						// uploadFile(arr[0]);
						// 测试用的
						list.value[currentIndex.value].url = arr[0].url;
					},
					fail: () => {},
				});
				break;
			case 'app':
				uni.chooseImage({
					count: 1,
					success: (res) => {
						let arr = res.tempFiles.map((item) => {
							return {
								type: 'image',
								url: item.path,
								size: item.size,
								name: item.name || '',
							};
						});
						// 正式使用时解开
						// uploadFile(arr[0]);
						// 测试用的
						list.value[currentIndex.value].url = arr[0].url;
					},
					fail: () => {},
				});

				break;
		}
	}
	async function uploadFile(file) {
		try {
			// 开始上传
			let res = await upload(file.url);
			return res.fullurl;
		} catch (error) {
			console.log(error);
		}
	}
	function upload(url) {
		return new Promise((resolve, reject) => {
			uni.uploadFile({
				url: '改成你的后端接口',
				filePath: url,
				name: 'file',
				formData: {},
				success: (res) => {
					const data = JSON.parse(res.data);
					// console.log('后端接口返回结果', data);
					if (data.code === 200) {
						resolve(data.data);
					} else {
						reject(data);
						uni.showToast({
							icon: 'error',
							title: data.msg,
						});
					}
				},
				fail: (e) => {
					uni.showToast({
						icon: 'error',
						title: '上传失败',
					});
					reject(e);
				},
			});
		});
	}

	// 素材操作元素的信息
	const controlInfo = ref('');
	watch(
		[() => list.value, () => currentIndex.value],
		async () => {
			if (currentIndex.value > -1) {
				await nextTick();
				addType.value = list.value[currentIndex.value].type;
				uni
					.createSelectorQuery()
					.in(proxy)
					.selectAll('.design-box-item')
					.boundingClientRect((arr) => {
						if (arr.length) {
							let data = arr[currentIndex.value];
							let space = 20;
							controlInfo.value = {
								width: data.width + space,
								height: data.height + space,
								left: data.left - space / 2,
								top: data.top - space / 2,
							};
						} else {
							controlInfo.value = '';
						}
					})
					.exec();
			} else {
				controlInfo.value = '';
			}
		},
		{
			deep: true,
		}
	);
	// 编辑素材
	function handleEdit() {
		if (addType.value == 'text') {
			let data = list.value[currentIndex.value];
			textOperateList.value[0].value = data.style.fontWeight == 'bold';
			textOperateList.value[1].value = data.style.fontStyle == 'italic';
			textOperateList.value[2].value = data.style.textDecoration.includes('underline');
			textOperateList.value[3].value = data.style.textDecoration.includes('line-through');
		}
		addPopup.value = true;
	}
	// 删除素材
	function handleDelete() {
		list.value.splice(currentIndex.value, 1);
		currentIndex.value = -1;
	}

	// 添加触摸和旋转相关的状态
	const isDragging = ref(false);
	const isRotating = ref(false);
	const isScaling = ref(false);
	const startX = ref(0);
	const startY = ref(0);
	const startPositionX = ref(0);
	const startPositionY = ref(0);
	const startAngle = ref(0);
	const startDistance = ref(0);
	const startScale = ref(1);
	const centerPoint = ref({ x: 0, y: 0 });

	// 触摸开始
	function handleTouchStart(event, index) {
		// 如果是双指触摸,不触发拖动
		if (event.touches.length === 2) {
			return;
		}

		currentIndex.value = index;
		isDragging.value = true;

		// 记录起始触摸点
		const touch = event.touches[0];
		startX.value = touch.clientX;
		startY.value = touch.clientY;

		// 记录元素初始位置
		startPositionX.value = list.value[index].positionX;
		startPositionY.value = list.value[index].positionY;
	}

	// 触摸移动
	function handleTouchMove(event) {
		// 如果是双指触摸,不触发拖动
		if (event.touches.length === 2) {
			return;
		}

		if (!isDragging.value) return;

		// 阻止页面滚动
		event.preventDefault();

		const touch = event.touches[0];
		const deltaX = touch.clientX - startX.value;
		const deltaY = touch.clientY - startY.value;

		// 更新元素位置
		list.value[currentIndex.value].positionX = startPositionX.value + deltaX;
		list.value[currentIndex.value].positionY = startPositionY.value + deltaY;
	}

	// 触摸结束
	function handleTouchEnd() {
		isDragging.value = false;
	}

	// 获取两点之间的角度
	function getAngle(center, point) {
		const x = point.x - center.x;
		const y = point.y - center.y;
		return (Math.atan2(y, x) * 180) / Math.PI;
	}

	// 获取两点之间的距离
	function getDistance(center, point) {
		const x = point.x - center.x;
		const y = point.y - center.y;
		return Math.sqrt(x * x + y * y);
	}

	// 缩放开始
	function handleScaleStart(event) {
		event.preventDefault();
		isScaling.value = true;

		const touch = event.touches[0];

		// 使用 uni-app 的 API 获取元素信息
		const query = uni.createSelectorQuery().in(proxy);
		query
			.selectAll('.design-box-item')
			.boundingClientRect((data) => {
				if (!data || !data[currentIndex.value]) return;

				const currentItem = data[currentIndex.value];
				centerPoint.value = {
					x: currentItem.left + currentItem.width / 2,
					y: currentItem.top + currentItem.height / 2,
				};

				// 记录起始距离和缩放值
				startDistance.value = getDistance(centerPoint.value, {
					x: touch.clientX,
					y: touch.clientY,
				});
				startScale.value = list.value[currentIndex.value].scale || 1;
			})
			.exec();
	}

	// 缩放移动
	function handleScaleMove(event) {
		if (!isScaling.value || !centerPoint.value.x) return;
		event.preventDefault();

		const touch = event.touches[0];
		const currentDistance = getDistance(centerPoint.value, {
			x: touch.clientX,
			y: touch.clientY,
		});

		// 计算缩放比例
		const scaleChange = currentDistance / startDistance.value;
		const newScale = startScale.value * scaleChange;

		// 限制缩放范围
		const minScale = 0.5;
		const maxScale = 3;
		list.value[currentIndex.value].scale = Math.min(Math.max(newScale, minScale), maxScale);
	}

	// 缩放结束
	function handleScaleEnd() {
		isScaling.value = false;
	}

	// 旋转开始
	function handleRotateStart(event) {
		event.preventDefault();
		isRotating.value = true;

		const touch = event.touches[0];

		const query = uni.createSelectorQuery().in(proxy);
		query
			.selectAll('.design-box-item')
			.boundingClientRect((data) => {
				if (!data || !data[currentIndex.value]) return;

				const currentItem = data[currentIndex.value];
				centerPoint.value = {
					x: currentItem.left + currentItem.width / 2,
					y: currentItem.top + currentItem.height / 2,
				};

				// 只计算起始角度
				startAngle.value =
					getAngle(centerPoint.value, {
						x: touch.clientX,
						y: touch.clientY,
					}) - (list.value[currentIndex.value].rotate || 0);
			})
			.exec();
	}

	// 旋转移动时只处理旋转
	function handleRotateMove(event) {
		if (!isRotating.value || !centerPoint.value.x) return;
		event.preventDefault();

		const touch = event.touches[0];
		const currentAngle = getAngle(centerPoint.value, {
			x: touch.clientX,
			y: touch.clientY,
		});

		// 只更新旋转角度
		list.value[currentIndex.value].rotate = currentAngle - startAngle.value;
	}

	// 旋转结束
	function handleRotateEnd() {
		isRotating.value = false;
	}

	// 双指缩放相关参数
	const isPinching = ref(false);
	const initialPinchDistance = ref(0);
	const initialScale = ref(1);
	// 双指旋转相关参数
	const initialRotation = ref(0);
	const initialElementRotation = ref(0);

	// 双指触摸开始处理函数
	function handleControlsTouchStart(event, index) {
		if (event.touches.length === 2) {
			event.preventDefault();
			event.stopPropagation();
			isPinching.value = true;
			isDragging.value = false;

			const touch1 = event.touches[0];
			const touch2 = event.touches[1];

			// 计算初始距离(用于缩放)
			initialPinchDistance.value = getPinchDistance(touch1, touch2);
			initialScale.value = list.value[currentIndex.value].scale || 1;

			// 计算初始角度(用于旋转)
			initialRotation.value = getTwoFingerAngle(touch1, touch2);
			initialElementRotation.value = list.value[currentIndex.value].rotate || 0;

			// 记录中心点
			centerPoint.value = {
				x: (touch1.clientX + touch2.clientX) / 2,
				y: (touch1.clientY + touch2.clientY) / 2,
			};
		} else if (event.touches.length === 1) {
			handleTouchStart(event, index);
		}
	}

	// 双指触摸移动处理函数
	function handleControlsTouchMove(event) {
		if (isPinching.value && event.touches.length === 2) {
			event.preventDefault();
			event.stopPropagation();

			const touch1 = event.touches[0];
			const touch2 = event.touches[1];

			// 处理缩放
			const currentDistance = getPinchDistance(touch1, touch2);
			const scaleChange = currentDistance / initialPinchDistance.value;
			const newScale = initialScale.value * scaleChange;

			// 限制缩放范围
			const minScale = 0.5;
			const maxScale = 3;
			list.value[currentIndex.value].scale = Math.min(Math.max(newScale, minScale), maxScale);

			// 处理旋转
			const currentRotation = getTwoFingerAngle(touch1, touch2);
			const rotationDiff = currentRotation - initialRotation.value;
			list.value[currentIndex.value].rotate = initialElementRotation.value + rotationDiff;
		} else if (event.touches.length === 1 && isDragging.value) {
			handleTouchMove(event);
		}
	}

	// 计算两个触摸点之间角度的函数
	function getTwoFingerAngle(touch1, touch2) {
		return (
			(Math.atan2(touch2.clientY - touch1.clientY, touch2.clientX - touch1.clientX) * 180) / Math.PI
		);
	}

	// 双指缩放的触摸处理函数 结束
	function handleControlsTouchEnd(event) {
		if (isPinching.value) {
			isPinching.value = false;
		}
		if (isDragging.value) {
			handleTouchEnd();
		}
	}

	// 计算两个触摸点之间的距离
	function getPinchDistance(touch1, touch2) {
		const dx = touch1.clientX - touch2.clientX;
		const dy = touch1.clientY - touch2.clientY;
		return Math.sqrt(dx * dx + dy * dy);
	}
</script>

<style lang="scss" scoped>
	.page-box {
		background: #ccc;
		min-height: 100vh;
		padding: 30rpx;
		position: relative;
	}

	.design-box {
		background: #fff;
		width: 60%;
		height: 600rpx;
		margin: 0 auto;
		position: relative;
		overflow: hidden;
	}
	.design-box-item {
		position: absolute;
		touch-action: manipulation;
		user-select: none;
		width: fit-content;
	}

	.controls-box {
		position: absolute;
		border: 1rpx dashed #19dbe9;
		touch-action: manipulation;
		.controls-scale {
			position: absolute;
			top: 0;
			left: 0;
			transform: translate(-50%, -50%);
		}
		.controls-edit {
			position: absolute;
			top: 0;
			right: 0;
			transform: translate(50%, -50%);
		}
		.controls-delete {
			position: absolute;
			bottom: 0;
			left: 0;
			transform: translate(-50%, 50%);
		}
		.controls-rotate {
			position: absolute;
			bottom: 0;
			right: 0;
			transform: translate(50%, 50%);
		}
	}

	.operate-box {
		margin-top: 40rpx;
		display: flex;
		justify-content: center;
		align-items: center;
		.operate-btn {
			padding: 10rpx 20rpx;
			display: flex;
			justify-content: center;
			align-items: center;
			border-radius: 10rpx;
			font-size: 26rpx;
			margin: 0 20rpx;
		}
		.operate-text {
			background: #4ceba8;
			color: #fff;
		}
		.operate-image {
			background: #279ae7;
			color: #fff;
		}
	}

	.add-popup-box {
		max-height: 40vh;
		overflow: auto;
		padding: 30rpx;
		padding-bottom: 60rpx;
		.title {
			display: flex;
			justify-content: center;
			font-size: 30rpx;
			font-weight: 600;
			color: #000;
			margin-bottom: 20rpx;
		}
		.add-text-box {
			.text-textarea {
				margin-bottom: 20rpx;
			}
			.text-operate-box {
				display: grid;
				grid-template-columns: repeat(4, 1fr);
				gap: 20rpx;
				.text-operate-item {
					display: flex;
					justify-content: center;
					align-items: center;
					border: 1rpx solid #ccc;
					border-radius: 10rpx;
					padding: 10rpx;
					font-size: 20rpx;
					color: #ccc;
				}
			}
			.text-color-box {
				margin-top: 20rpx;
				display: flex;
				align-items: center;
				flex-wrap: wrap;
				.text-color-item {
					width: 50rpx;
					height: 50rpx;
					border-radius: 50%;
					margin: 0 10rpx;
				}
			}
			.text-font-box {
				margin-top: 20rpx;
				display: flex;
				align-items: center;
				flex-wrap: wrap;
				.text-font-item {
					border: 1rpx solid #ccc;
					border-radius: 10rpx;
					padding: 10rpx;
					font-size: 20rpx;
					color: #ccc;
					display: flex;
					justify-content: center;
					align-items: center;
					margin: 0 10rpx;
				}
			}
		}
		.add-image-box {
			display: flex;
			flex-wrap: wrap;
			.add-image-item {
				width: 100rpx;
				height: 100rpx;
				border-radius: 10rpx;
				margin: 0 10rpx;
				image {
					width: 100%;
					height: 100%;
				}
			}
			.cursor-pointer {
				width: 100rpx;
				height: 100rpx;
				border-radius: 10rpx;
				margin: 0 10rpx;
				display: flex;
				justify-content: center;
				align-items: center;
				border: 1rpx dashed #ccc;
			}
		}
	}
</style>

注意点

里面的myPopup是我另一个弹出层组件:juejin.cn/post/744678… 可根据项目情况自行更换

确保设计页面不可滚动,也就是需要一屏展示完页面内容,不然移动素材的时候会出现错位问题

当前支持平台

  • 微信小程序
  • H5
  • App(安卓)
  • App(苹果)未测试