uniapp 拍照相册选取后超级好用的裁剪组件,增加水印完全自定义

1.超级多的裁剪模式,固定格式裁剪,自由裁剪

身份证,银行卡,a4纸,1寸,2寸,不传默认自由裁剪

javascript 复制代码
const cropPresets = {
	idCard: { widthMM: 85.6, heightMM: 54, lockCropRatio: true, lockedCropBox: true },
	bankCard: { widthMM: 85.6, heightMM: 53.98, lockCropRatio: true, lockedCropBox: true },
	householdRegister: { widthMM: 143, heightMM: 105, lockCropRatio: true, lockedCropBox: true },
	a4: { widthMM: 210, heightMM: 297, lockCropRatio: true, lockedCropBox: true },
	a4HalfShort: { widthMM: 210, heightMM: 140, lockCropRatio: true, lockedCropBox: true },
	idPhoto1: { widthMM: 25, heightMM: 35, lockCropRatio: true, lockedCropBox: true },
	idPhoto2: { widthMM: 35, heightMM: 49, lockCropRatio: true, lockedCropBox: true }
};

2.水印自己随意定义

水印采用数组对象格式,随便定义

javascript 复制代码
const watermarkList = ref([
	{ key: '', value: '一个项目' },
	{ key: '用  户:', value: '' },
	{ key: '品  牌:', value: '海尔' },
	{ key: '施工内容:', value: '安装热风机' },
	{ key: '拍摄时间:', type: 'time' },
	{ key: '拍摄地点:', type: 'location' }
]);
const watermarkLocation = ref('');
javascript 复制代码
<template>
	<view class="print-generator">
		<canvas v-if="exportCanvasVisible" :canvas-id="exportCanvasId" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }" class="hidden-canvas"></canvas>
		<view v-if="cropVisible" class="crop-mask">
			<view class="crop-header">
				<text class="crop-title">{{ cropTitle }}</text>
			</view>

			<view
				class="crop-stage"
				:style="{ width: stageWidth + 'px', height: stageHeight + 'px' }"
				@touchstart="onTouchStart"
				@touchmove.stop.prevent="onTouchMove"
				@touchend="onTouchEnd"
				@touchcancel="onTouchEnd"
			>
				<view v-if="cropImagePath" class="crop-image-wrap" :style="imageWrapStyle">
					<image class="crop-image" :src="cropImagePath" mode="aspectFill" :style="imageStyle"></image>
				</view>
				<view v-if="watermarkPreviewLines.length" class="watermark-preview" :style="watermarkPreviewStyle">
					<text v-for="(line, index) in watermarkPreviewLines" :key="index" class="watermark-preview-line">{{ line }}</text>
				</view>
				<view v-if="!isWatermarkOnly" class="crop-shadow crop-shadow-top" :style="shadowTopStyle"></view>
				<view v-if="!isWatermarkOnly" class="crop-shadow crop-shadow-bottom" :style="shadowBottomStyle"></view>
				<view v-if="!isWatermarkOnly" class="crop-shadow crop-shadow-left" :style="shadowLeftStyle"></view>
				<view v-if="!isWatermarkOnly" class="crop-shadow crop-shadow-right" :style="shadowRightStyle"></view>
				<view
					v-if="!isWatermarkOnly"
					class="crop-box"
					:style="cropBoxStyle"
					@touchstart.stop="onCropBoxTouchStart"
					@touchmove.stop.prevent="onCropBoxTouchMove"
					@touchend.stop="onTouchEnd"
					@touchcancel.stop="onTouchEnd"
				>
					<view class="grid-line grid-line-v grid-line-v1"></view>
					<view class="grid-line grid-line-v grid-line-v2"></view>
					<view class="grid-line grid-line-h grid-line-h1"></view>
					<view class="grid-line grid-line-h grid-line-h2"></view>
					<template v-if="!isCropBoxLocked">
						<view
							v-for="handle in resizeHandles"
							:key="handle"
							:class="['resize-handle', 'resize-handle-' + handle]"
							@touchstart.stop="onResizeTouchStart(handle, $event)"
							@touchmove.stop.prevent="onResizeTouchMove"
							@touchend.stop="onTouchEnd"
							@touchcancel.stop="onTouchEnd"
						></view>
					</template>
				</view>
			</view>

			<view v-if="!isWatermarkOnly" class="crop-tools">
				<button class="tool-btn" @click="rotateCropImage">
					<text class="tool-icon">↻</text>
					<text class="tool-text">{{ labels.rotate }}</text>
				</button>
				<button class="tool-btn" :class="{ disabled: !canRestore }" @click="restoreCropImage">
					<text class="tool-text">{{ labels.restore }}</text>
				</button>
			</view>

			<view class="crop-actions">
				<button class="crop-btn crop-btn-cancel" @click="cancelCrop">{{ labels.cancel }}</button>
				<button class="crop-btn crop-btn-confirm" @click="confirmCrop">{{ labels.confirm }}</button>
			</view>
		</view>
	</view>
</template>

<script setup>
import { ref, computed, nextTick, getCurrentInstance } from 'vue';

const canvasInstance = getCurrentInstance();

const labels = {
	title: '\u8c03\u6574\u88c1\u526a',
	watermarkTitle: '\u786e\u8ba4\u6c34\u5370',
	rotate: '\u65cb\u8f6c',
	restore: '\u8fd8\u539f',
	cancel: '\u53d6\u6d88',
	confirm: '\u786e\u8ba4'
};

const resizeHandles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];

const cropPresets = {
	idCard: { widthMM: 85.6, heightMM: 54, lockCropRatio: true, lockedCropBox: true },
	bankCard: { widthMM: 85.6, heightMM: 53.98, lockCropRatio: true, lockedCropBox: true },
	householdRegister: { widthMM: 143, heightMM: 105, lockCropRatio: true, lockedCropBox: true },
	a4: { widthMM: 210, heightMM: 297, lockCropRatio: true, lockedCropBox: true },
	a4HalfShort: { widthMM: 210, heightMM: 140, lockCropRatio: true, lockedCropBox: true },
	idPhoto1: { widthMM: 25, heightMM: 35, lockCropRatio: true, lockedCropBox: true },
	idPhoto2: { widthMM: 35, heightMM: 49, lockCropRatio: true, lockedCropBox: true }
};

const canvasWidth = ref(0);
const canvasHeight = ref(0);
const exportCanvasVisible = ref(false);
const exportCanvasId = ref('posterCanvas');
const previewUrl = ref('');

const cropVisible = ref(false);
const cropImagePath = ref('');
const cropOptions = ref(null);
const cropResolver = ref(null);
const cropRejecter = ref(null);
const imageInfo = ref(null);
const stageWidth = ref(0);
const stageHeight = ref(0);
const cropBoxWidth = ref(0);
const cropBoxHeight = ref(0);
const cropBoxX = ref(0);
const cropBoxY = ref(0);
const cropBoxMin = ref(0);
const cropBoxMax = ref(0);
const imageBaseWidth = ref(0);
const imageBaseHeight = ref(0);
const imageX = ref(0);
const imageY = ref(0);
const imageScale = ref(1);
const rotationAngle = ref(0);
const touchStart = ref(null);
const originalImagePath = ref('');
const originalImageInfo = ref(null);
const canRestore = computed(() => rotationAngle.value !== 0 || cropImagePath.value !== originalImagePath.value);
const isCropBoxLocked = computed(() => !!(cropOptions.value && cropOptions.value.lockedCropBox));
const isWatermarkOnly = computed(() => !!(cropOptions.value && cropOptions.value.watermarkOnly));
const cropTitle = computed(() => (isWatermarkOnly.value ? labels.watermarkTitle : labels.title));

const formatDateTime = (date = new Date()) => {
	const pad = (value) => String(value).padStart(2, '0');
	return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};

const normalizeWatermark = (watermark) => {
	if (!watermark) return null;
	if (Array.isArray(watermark)) return { enabled: true, items: watermark };
	if (watermark === true) return { enabled: true };
	if (typeof watermark === 'string') return { enabled: true, location: watermark };
	return {
		enabled: true,
		...watermark
	};
};

const getWatermarkLines = (watermark) => {
	const config = normalizeWatermark(watermark);
	if (!config) return [];
	if (Array.isArray(config.lines) && config.lines.length) return config.lines.filter(Boolean);
	if (Array.isArray(config.items) && config.items.length) {
		return config.items
			.map((item) => {
				if (!item) return '';
				if (typeof item === 'string') return item;
				const key = item.key || item.label || item.name || '';
				let value = item.value;
				if (value === undefined || value === null || value === '') {
					if (item.type === 'time') value = config.time || config.datetime || formatDateTime();
					if (item.type === 'location') value = config.location || '\u672a\u77e5\u5730\u70b9';
				}
				return `${key}${value || ''}`;
			})
			.filter(Boolean);
	}

	const lines = [];
	if (config.text) lines.push(config.text);
	if (config.showTime !== false) lines.push(config.time || config.datetime || formatDateTime());
	if (config.showLocation !== false) lines.push(config.location || '\u672a\u77e5\u5730\u70b9');
	if (config.remark) lines.push(config.remark);
	return lines;
};

const watermarkPreviewLines = computed(() => getWatermarkLines(cropOptions.value && cropOptions.value.watermark));
const watermarkPreviewStyle = computed(() => ({
	left: imageX.value + 12 + 'px',
	top: imageY.value + imageBaseHeight.value * imageScale.value - Math.max(watermarkPreviewLines.value.length, 1) * 22 - 14 + 'px'
}));
const getImageInfo = (src) => {
	return new Promise((resolve, reject) => {
		uni.getImageInfo({
			src,
			success: resolve,
			fail: (err) => reject(new Error('getImageInfo failed: ' + JSON.stringify(err)))
		});
	});
};

const waitNextTick = () => {
	return new Promise((resolve) => nextTick(resolve));
};

const clamp = (value, min, max) => {
	return Math.min(Math.max(value, min), max);
};

const calcCenterCropRect = (imageWidth, imageHeight, targetWidth, targetHeight) => {
	const imageRatio = imageWidth / imageHeight;
	const targetRatio = targetWidth / targetHeight;
	let sx = 0;
	let sy = 0;
	let sw = imageWidth;
	let sh = imageHeight;

	if (imageRatio > targetRatio) {
		sw = imageHeight * targetRatio;
		sx = (imageWidth - sw) / 2;
	} else if (imageRatio < targetRatio) {
		sh = imageWidth / targetRatio;
		sy = (imageHeight - sh) / 2;
	}

	return { sx, sy, sw, sh };
};

const getDisplaySize = (info, rotation = rotationAngle.value) => {
	const rotated = rotation % 180 !== 0;
	return {
		width: rotated ? info.height : info.width,
		height: rotated ? info.width : info.height
	};
};

const getCropBoxLeft = () => cropBoxX.value;
const getCropBoxTop = () => cropBoxY.value;

const constrainCropBoxPosition = () => {
	cropBoxX.value = clamp(cropBoxX.value, 0, Math.max(stageWidth.value - cropBoxWidth.value, 0));
	cropBoxY.value = clamp(cropBoxY.value, 0, Math.max(stageHeight.value - cropBoxHeight.value, 0));
};

const constrainImagePosition = () => {
	const scaledWidth = imageBaseWidth.value * imageScale.value;
	const scaledHeight = imageBaseHeight.value * imageScale.value;
	const cropLeft = getCropBoxLeft();
	const cropTop = getCropBoxTop();
	const minX = cropLeft + cropBoxWidth.value - scaledWidth;
	const maxX = cropLeft;
	const minY = cropTop + cropBoxHeight.value - scaledHeight;
	const maxY = cropTop;

	imageX.value = minX > maxX ? (stageWidth.value - scaledWidth) / 2 : clamp(imageX.value, minX, maxX);
	imageY.value = minY > maxY ? (stageHeight.value - scaledHeight) / 2 : clamp(imageY.value, minY, maxY);
};

const ensureImageCoversCropBox = () => {
	const minScale = Math.max(cropBoxWidth.value / imageBaseWidth.value, cropBoxHeight.value / imageBaseHeight.value, 1);

	if (imageScale.value < minScale) {
		imageScale.value = minScale;
	}
};

const applyFreeCropBoxRect = (nextX, nextY, nextWidth, nextHeight) => {
	const minSize = cropBoxMin.value;
	let width = clamp(nextWidth, minSize, stageWidth.value);
	let height = clamp(nextHeight, minSize, stageHeight.value);
	let x = nextX;
	let y = nextY;

	if (x < 0) {
		width += x;
		x = 0;
	}

	if (y < 0) {
		height += y;
		y = 0;
	}

	if (x + width > stageWidth.value) {
		width = stageWidth.value - x;
	}

	if (y + height > stageHeight.value) {
		height = stageHeight.value - y;
	}

	cropBoxWidth.value = Math.max(width, minSize);
	cropBoxHeight.value = Math.max(height, minSize);
	cropBoxX.value = x;
	cropBoxY.value = y;
	constrainCropBoxPosition();
	ensureImageCoversCropBox();
	constrainImagePosition();
};

const applyLockedCropBoxRect = (nextX, nextY, nextWidth) => {
	const targetRatio = cropOptions.value.widthMM / cropOptions.value.heightMM;
	const minWidth = cropBoxMin.value;
	const maxWidth = Math.min(cropBoxMax.value, stageWidth.value, stageHeight.value * targetRatio);
	let width = clamp(nextWidth, minWidth, maxWidth);
	let height = width / targetRatio;

	if (height > stageHeight.value) {
		height = stageHeight.value;
		width = height * targetRatio;
	}

	cropBoxWidth.value = width;
	cropBoxHeight.value = height;
	cropBoxX.value = nextX;
	cropBoxY.value = nextY;
	constrainCropBoxPosition();
	ensureImageCoversCropBox();
	constrainImagePosition();
};

const resizeCropBoxFromCenter = (nextWidth, anchorX, anchorY) => {
	const targetRatio = cropOptions.value.widthMM / cropOptions.value.heightMM;
	const width = nextWidth;
	const height = width / targetRatio;
	applyLockedCropBoxRect(anchorX - width / 2, anchorY - height / 2, width);
};

const resizeCropBoxFromEdge = (handle, nextWidth, start) => {
	const targetRatio = cropOptions.value.widthMM / cropOptions.value.heightMM;
	const oldRight = start.cropBoxX + start.cropBoxWidth;
	const oldBottom = start.cropBoxY + start.cropBoxHeight;
	const width = nextWidth;
	const height = width / targetRatio;
	let nextX = start.cropBoxX;
	let nextY = start.cropBoxY;

	if (handle.includes('w')) {
		nextX = oldRight - width;
	}

	if (handle.includes('n')) {
		nextY = oldBottom - height;
	}

	if (handle === 'n' || handle === 's') {
		nextX = start.cropBoxX + (start.cropBoxWidth - width) / 2;
	}

	if (handle === 'e' || handle === 'w') {
		nextY = start.cropBoxY + (start.cropBoxHeight - height) / 2;
	}

	applyLockedCropBoxRect(nextX, nextY, width);
};

const resizeCropBoxFree = (handle, dx, dy, start) => {
	const oldRight = start.cropBoxX + start.cropBoxWidth;
	const oldBottom = start.cropBoxY + start.cropBoxHeight;
	let nextX = start.cropBoxX;
	let nextY = start.cropBoxY;
	let nextWidth = start.cropBoxWidth;
	let nextHeight = start.cropBoxHeight;

	if (handle.includes('e')) {
		nextWidth = start.cropBoxWidth + dx;
	}

	if (handle.includes('w')) {
		nextX = start.cropBoxX + dx;
		nextWidth = oldRight - nextX;
	}

	if (handle.includes('s')) {
		nextHeight = start.cropBoxHeight + dy;
	}

	if (handle.includes('n')) {
		nextY = start.cropBoxY + dy;
		nextHeight = oldBottom - nextY;
	}

	applyFreeCropBoxRect(nextX, nextY, nextWidth, nextHeight);
};

const initCropState = async (options, info) => {
	const systemInfo = uni.getSystemInfoSync();
	const windowWidth = systemInfo.windowWidth || 375;
	const windowHeight = systemInfo.windowHeight || 667;
	const displaySize = getDisplaySize(info);
	const targetRatio = options.watermarkOnly ? displaySize.width / displaySize.height : options.widthMM / options.heightMM;
	const maxStageWidth = Math.min(windowWidth - 12, 460);
	const maxStageHeight = Math.min(windowHeight * 0.66, 600);

	stageWidth.value = maxStageWidth;
	stageHeight.value = maxStageHeight;

	let defaultBoxWidth = Math.min(maxStageWidth * 0.9, maxStageHeight * 0.86 * targetRatio);
	let defaultBoxHeight = defaultBoxWidth / targetRatio;

	if (defaultBoxHeight > maxStageHeight * 0.86) {
		defaultBoxHeight = maxStageHeight * 0.86;
		defaultBoxWidth = defaultBoxHeight * targetRatio;
	}

	cropBoxMin.value = Math.max(120, Math.min(defaultBoxWidth, defaultBoxHeight) * 0.55);
	cropBoxMax.value = Math.min(maxStageWidth - 32, (maxStageHeight - 32) * targetRatio);
	cropBoxWidth.value = defaultBoxWidth;
	cropBoxHeight.value = defaultBoxHeight;
	cropBoxX.value = (stageWidth.value - cropBoxWidth.value) / 2;
	cropBoxY.value = (stageHeight.value - cropBoxHeight.value) / 2;

	const imageRatio = displaySize.width / displaySize.height;
	const boxRatio = cropBoxWidth.value / cropBoxHeight.value;

	if (imageRatio > boxRatio) {
		imageBaseHeight.value = cropBoxHeight.value;
		imageBaseWidth.value = imageBaseHeight.value * imageRatio;
	} else {
		imageBaseWidth.value = cropBoxWidth.value;
		imageBaseHeight.value = imageBaseWidth.value / imageRatio;
	}

	imageScale.value = 1;
	imageX.value = getCropBoxLeft() + (cropBoxWidth.value - imageBaseWidth.value) / 2;
	imageY.value = getCropBoxTop() + (cropBoxHeight.value - imageBaseHeight.value) / 2;
	constrainImagePosition();
	await waitNextTick();
};

const imageWrapStyle = computed(() => ({
	width: imageBaseWidth.value + 'px',
	height: imageBaseHeight.value + 'px',
	transform: `translate(${imageX.value}px, ${imageY.value}px) scale(${imageScale.value})`,
	transformOrigin: '0 0'
}));

const imageStyle = computed(() => {
	const rotated = rotationAngle.value % 180 !== 0;
	const width = rotated ? imageBaseHeight.value : imageBaseWidth.value;
	const height = rotated ? imageBaseWidth.value : imageBaseHeight.value;

	return {
		width: width + 'px',
		height: height + 'px',
		transform: `translate(${(imageBaseWidth.value - width) / 2}px, ${(imageBaseHeight.value - height) / 2}px) rotate(${rotationAngle.value}deg)`,
		transformOrigin: 'center center'
	};
});

const cropBoxStyle = computed(() => ({
	left: getCropBoxLeft() + 'px',
	top: getCropBoxTop() + 'px',
	width: cropBoxWidth.value + 'px',
	height: cropBoxHeight.value + 'px'
}));

const shadowTopStyle = computed(() => ({
	left: 0,
	top: 0,
	width: stageWidth.value + 'px',
	height: getCropBoxTop() + 'px'
}));

const shadowBottomStyle = computed(() => ({
	left: 0,
	top: getCropBoxTop() + cropBoxHeight.value + 'px',
	width: stageWidth.value + 'px',
	height: stageHeight.value - getCropBoxTop() - cropBoxHeight.value + 'px'
}));

const shadowLeftStyle = computed(() => ({
	left: 0,
	top: getCropBoxTop() + 'px',
	width: getCropBoxLeft() + 'px',
	height: cropBoxHeight.value + 'px'
}));

const shadowRightStyle = computed(() => ({
	left: getCropBoxLeft() + cropBoxWidth.value + 'px',
	top: getCropBoxTop() + 'px',
	width: stageWidth.value - getCropBoxLeft() - cropBoxWidth.value + 'px',
	height: cropBoxHeight.value + 'px'
}));

const onTouchStart = (event) => {
	if (isWatermarkOnly.value) return;
	if (event.touches && event.touches.length >= 2) {
		const first = event.touches[0];
		const second = event.touches[1];
		touchStart.value = {
			type: 'pinch',
			distance: Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY),
			scale: imageScale.value
		};
		return;
	}

	const touch = event.touches && event.touches[0];
	if (!touch) return;
	touchStart.value = {
		x: touch.clientX,
		y: touch.clientY,
		imageX: imageX.value,
		imageY: imageY.value
	};
};

const onTouchMove = (event) => {
	if (isWatermarkOnly.value) return;
	if (event.touches && event.touches.length >= 2 && touchStart.value && touchStart.value.type === 'pinch') {
		const first = event.touches[0];
		const second = event.touches[1];
		const distance = Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
		imageScale.value = clamp(touchStart.value.scale * (distance / touchStart.value.distance), 1, cropOptions.value.maxImageScale || 4);
		ensureImageCoversCropBox();
		constrainImagePosition();
		return;
	}

	const touch = event.touches && event.touches[0];
	if (!touch || !touchStart.value || touchStart.value.type) return;
	imageX.value = touchStart.value.imageX + touch.clientX - touchStart.value.x;
	imageY.value = touchStart.value.imageY + touch.clientY - touchStart.value.y;
	constrainImagePosition();
};

const onTouchEnd = () => {
	touchStart.value = null;
};

const onCropBoxTouchStart = (event) => {
	if (isWatermarkOnly.value) return;
	if (isCropBoxLocked.value) {
		onTouchStart(event);
		return;
	}
	const touch = event.touches && event.touches[0];
	if (!touch) return;
	touchStart.value = {
		type: 'cropBox',
		x: touch.clientX,
		y: touch.clientY,
		cropBoxX: cropBoxX.value,
		cropBoxY: cropBoxY.value
	};
};

const onCropBoxTouchMove = (event) => {
	if (isWatermarkOnly.value) return;
	if (isCropBoxLocked.value) {
		onTouchMove(event);
		return;
	}
	const touch = event.touches && event.touches[0];
	if (!touch || !touchStart.value || touchStart.value.type !== 'cropBox') return;
	cropBoxX.value = touchStart.value.cropBoxX + touch.clientX - touchStart.value.x;
	cropBoxY.value = touchStart.value.cropBoxY + touch.clientY - touchStart.value.y;
	constrainCropBoxPosition();
	ensureImageCoversCropBox();
	constrainImagePosition();
};

const onResizeTouchStart = (handle, event) => {
	if (isWatermarkOnly.value) return;
	if (isCropBoxLocked.value) return;
	const touch = event.touches && event.touches[0];
	if (!touch) return;
	touchStart.value = {
		type: 'resize',
		handle,
		x: touch.clientX,
		y: touch.clientY,
		cropBoxX: cropBoxX.value,
		cropBoxY: cropBoxY.value,
		cropBoxWidth: cropBoxWidth.value,
		cropBoxHeight: cropBoxHeight.value
	};
};

const onResizeTouchMove = (event) => {
	if (isCropBoxLocked.value) return;
	const touch = event.touches && event.touches[0];
	const start = touchStart.value;
	if (!touch || !start || start.type !== 'resize') return;

	const dx = touch.clientX - start.x;
	const dy = touch.clientY - start.y;
	const centerX = start.cropBoxX + start.cropBoxWidth / 2;
	const centerY = start.cropBoxY + start.cropBoxHeight / 2;
	let width = start.cropBoxWidth;
	const handle = start.handle;

	if (!cropOptions.value.lockCropRatio) {
		resizeCropBoxFree(handle, dx, dy, start);
		return;
	}

	if (handle.includes('e')) width = start.cropBoxWidth + dx * 2;
	if (handle.includes('w')) width = start.cropBoxWidth - dx * 2;
	if (handle === 'n') width = (start.cropBoxHeight - dy * 2) * (cropOptions.value.widthMM / cropOptions.value.heightMM);
	if (handle === 's') width = (start.cropBoxHeight + dy * 2) * (cropOptions.value.widthMM / cropOptions.value.heightMM);
	if (handle === 'ne' || handle === 'sw') width = start.cropBoxWidth + (dx - dy) * (handle === 'ne' ? 1 : -1);
	if (handle === 'nw' || handle === 'se') width = start.cropBoxWidth + (dx + dy) * (handle === 'se' ? 1 : -1);

	if (cropOptions.value.resizeFromCenter) {
		resizeCropBoxFromCenter(width, centerX, centerY);
	} else {
		resizeCropBoxFromEdge(handle, width, start);
	}
};

const openCropper = async (options, info) => {
	cropImagePath.value = info.path || options.imagePath;
	originalImagePath.value = cropImagePath.value;
	originalImageInfo.value = info;
	rotationAngle.value = 0;
	cropOptions.value = options;
	imageInfo.value = info;
	cropVisible.value = true;
	await initCropState(options, info);

	return new Promise((resolve, reject) => {
		cropResolver.value = resolve;
		cropRejecter.value = reject;
	});
};

const restoreCropImage = async () => {
	if (!canRestore.value || !originalImageInfo.value) return;
	cropImagePath.value = originalImagePath.value;
	imageInfo.value = originalImageInfo.value;
	rotationAngle.value = 0;
	await initCropState(cropOptions.value, originalImageInfo.value);
};

const rotateCropImage = async () => {
	rotationAngle.value = (rotationAngle.value + 90) % 360;
	await initCropState(cropOptions.value, imageInfo.value);
};

const getManualCropRect = () => {
	const info = imageInfo.value;
	const displaySize = getDisplaySize(info);

	if (isWatermarkOnly.value) {
		return {
			sx: 0,
			sy: 0,
			sw: displaySize.width,
			sh: displaySize.height
		};
	}

	const scaledWidth = imageBaseWidth.value * imageScale.value;
	const scaledHeight = imageBaseHeight.value * imageScale.value;
	const cropLeft = getCropBoxLeft();
	const cropTop = getCropBoxTop();

	const rect = {
		sx: ((cropLeft - imageX.value) / scaledWidth) * displaySize.width,
		sy: ((cropTop - imageY.value) / scaledHeight) * displaySize.height,
		sw: (cropBoxWidth.value / scaledWidth) * displaySize.width,
		sh: (cropBoxHeight.value / scaledHeight) * displaySize.height
	};

	rect.sx = clamp(rect.sx, 0, displaySize.width - 1);
	rect.sy = clamp(rect.sy, 0, displaySize.height - 1);
	rect.sw = clamp(rect.sw, 1, displaySize.width - rect.sx);
	rect.sh = clamp(rect.sh, 1, displaySize.height - rect.sy);

	return rect;
};

const closeCropper = () => {
	cropVisible.value = false;
	cropResolver.value = null;
	cropRejecter.value = null;
	touchStart.value = null;
};

const cancelCrop = () => {
	if (cropRejecter.value) {
		cropRejecter.value(new Error('crop canceled'));
	}
	closeCropper();
};

const confirmCrop = () => {
	if (cropResolver.value) {
		const displaySize = getDisplaySize(imageInfo.value);
		cropResolver.value({
			rect: getManualCropRect(),
			outputWidth: isWatermarkOnly.value ? Math.round(displaySize.width) : Math.round(cropBoxWidth.value),
			outputHeight: isWatermarkOnly.value ? Math.round(displaySize.height) : Math.round(cropBoxHeight.value),
			imagePath: cropImagePath.value,
			imageInfo: imageInfo.value,
			rotation: rotationAngle.value
		});
	}
	closeCropper();
};

const drawImageWithCrop = (ctx, imagePath, info, cropRect, rotation, outputWidth, outputHeight) => {
	if (!rotation) {
		ctx.drawImage(imagePath, cropRect.sx, cropRect.sy, cropRect.sw, cropRect.sh, 0, 0, outputWidth, outputHeight);
		return;
	}

	const displaySize = getDisplaySize(info, rotation);

	ctx.save();
	ctx.beginPath();
	ctx.rect(0, 0, outputWidth, outputHeight);
	ctx.clip();
	ctx.translate(-cropRect.sx * (outputWidth / cropRect.sw), -cropRect.sy * (outputHeight / cropRect.sh));
	ctx.scale(outputWidth / cropRect.sw, outputHeight / cropRect.sh);
	ctx.translate(displaySize.width / 2, displaySize.height / 2);
	ctx.rotate((rotation * Math.PI) / 180);
	ctx.drawImage(imagePath, -info.width / 2, -info.height / 2, info.width, info.height);
	ctx.restore();
};

const drawWatermark = (ctx, outputWidth, outputHeight, watermark) => {
	const lines = getWatermarkLines(watermark);
	if (!lines.length) return;

	const size = watermark.size || Math.max(14, Math.round(outputWidth / 36));
	const padding = watermark.padding || Math.round(size * 0.9);
	const lineHeight = watermark.lineHeight || Math.round(size * 1.45);
	const x = padding;
	const y = outputHeight - padding - (lines.length - 1) * lineHeight;

	ctx.save();
	ctx.setFontSize(size);
	ctx.setTextAlign('left');
	ctx.setTextBaseline('middle');
	lines.forEach((line, index) => {
		const lineY = y + index * lineHeight;
		ctx.setFillStyle(watermark.shadowColor || 'rgba(0, 0, 0, 0.55)');
		ctx.fillText(line, x + 1, lineY + 1);
		ctx.setFillStyle(watermark.color || '#FFFFFF');
		ctx.fillText(line, x, lineY);
	});
	ctx.restore();
};

const drawToTempFile = async (options, info, cropRect, drawImagePath, rotation = 0, cropResult = null) => {
	const { widthMM, heightMM, dpi, watermark } = options;
	const rawPxWidth = Math.round((widthMM / 25.4) * dpi);
	const rawPxHeight = Math.round((heightMM / 25.4) * dpi);
	const cropOutputWidth = cropResult && cropResult.outputWidth ? cropResult.outputWidth : Math.round(cropRect.sw);
	const cropOutputHeight = cropResult && cropResult.outputHeight ? cropResult.outputHeight : Math.round(cropRect.sh);
	const cropQualityScale = options.outputMode === 'print' ? 1 : options.cropQualityScale || options.outputPixelRatio || 2;
	const baseOutputWidth = options.outputMode === 'print' ? rawPxWidth : Math.round(cropOutputWidth * cropQualityScale);
	const baseOutputHeight = options.outputMode === 'print' ? rawPxHeight : Math.round(cropOutputHeight * cropQualityScale);
	const outputScale = Math.min((options.maxOutputSize || 1200) / Math.max(baseOutputWidth, baseOutputHeight), 1);
	const pxWidth = Math.round(baseOutputWidth * outputScale);
	const pxHeight = Math.round(baseOutputHeight * outputScale);
	const canvasId = `posterCanvas_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
	exportCanvasId.value = canvasId;
	canvasWidth.value = pxWidth;
	canvasHeight.value = pxHeight;
	exportCanvasVisible.value = true;

	await waitNextTick();
	await new Promise((resolve) => setTimeout(resolve, 50));

	return new Promise((resolve, reject) => {
		let settled = false;
		const destroyCanvas = () => {
			exportCanvasVisible.value = false;
			canvasWidth.value = 0;
			canvasHeight.value = 0;
		};
		const timer = setTimeout(() => {
			if (settled) return;
			settled = true;
			destroyCanvas();
			reject(new Error('canvas export timeout'));
		}, options.exportTimeout || 8000);
		const ctx = uni.createCanvasContext(canvasId, canvasInstance);

		ctx.setFillStyle('#FFFFFF');
		ctx.fillRect(0, 0, pxWidth, pxHeight);

		drawImageWithCrop(ctx, drawImagePath || info.path || options.imagePath, info, cropRect, rotation, pxWidth, pxHeight);

		drawWatermark(ctx, pxWidth, pxHeight, watermark);

		const exportCanvas = () => {
			uni.canvasToTempFilePath(
				{
					canvasId,
					destWidth: pxWidth,
					destHeight: pxHeight,
					fileType: 'jpg',
					quality: 1,
					success: (res) => {
						if (settled) return;
						settled = true;
						clearTimeout(timer);
						previewUrl.value = res.tempFilePath;
						destroyCanvas();
						resolve(res.tempFilePath);
					},
					fail: (err) => {
						if (settled) return;
						settled = true;
						clearTimeout(timer);
						destroyCanvas();
						reject(new Error('canvasToTempFilePath failed: ' + JSON.stringify(err)));
					}
				},
				canvasInstance
			);
		};

		ctx.draw(false, () => {
			setTimeout(exportCanvas, options.exportDelay || 100);
		});
	});
};

const generate = async (options) => {
	const preset = options && options.preset ? (typeof options.preset === 'string' ? cropPresets[options.preset] : options.preset) : null;
	const config = {
		widthMM: 25,
		heightMM: 35,
		dpi: 300,
		watermark: null,
		manualCrop: true,
		outputMode: 'crop',
		resizeFromCenter: false,
		lockCropRatio: false,
		maxImageScale: 4,
		maxOutputSize: 2400,
		cropQualityScale: 2,
		exportDelay: 100,
		...(preset || {}),
		...options
	};
	config.watermark = normalizeWatermark(config.watermark);
	if (config.watermark) {
		config.watermarkOnly = config.watermarkOnly !== false;
	}

	if (!config.imagePath) {
		throw new Error('imagePath is required');
	}

	uni.hideLoading();

	const info = await getImageInfo(config.imagePath);
	const targetPxWidth = Math.round((config.widthMM / 25.4) * config.dpi);
	const targetPxHeight = Math.round((config.heightMM / 25.4) * config.dpi);
	const cropResult = config.manualCrop
		? await openCropper(config, info)
		: {
				rect: calcCenterCropRect(info.width, info.height, targetPxWidth, targetPxHeight),
				outputWidth: targetPxWidth,
				outputHeight: targetPxHeight,
				imagePath: info.path || config.imagePath,
				imageInfo: info,
				rotation: 0
		  };

	if (config.showLoading !== false) {
		uni.showLoading({ title: '生成中...' });
	}

	try {
		return await drawToTempFile(config, cropResult.imageInfo, cropResult.rect, cropResult.imagePath, cropResult.rotation, cropResult);
	} finally {
		if (config.showLoading !== false) {
			uni.hideLoading();
		}
	}
};
const chooseImage = (options = {}) => {
	return new Promise((resolve, reject) => {
		uni.chooseImage({
			count: 1,
			sourceType: ['album', 'camera'],
			...options,
			success: (res) => {
				const imagePath = res.tempFilePaths && res.tempFilePaths[0];
				if (!imagePath) {
					reject(new Error('chooseImage returned empty path'));
					return;
				}
				resolve(imagePath);
			},
			fail: reject
		});
	});
};
const chooseAndGenerate = async (options = {}) => {
	const { chooseOptions, ...generateOptions } = options;
	const imagePath = generateOptions.imagePath || await chooseImage(chooseOptions);
	return generate({
		...generateOptions,
		imagePath
	});
};
defineExpose({
	generate,
	chooseAndGenerate
});
</script>

<style scoped>
.print-generator {
	position: relative;
	width: 1px;
	height: 1px;
	overflow: hidden;
}

.hidden-canvas {
	position: fixed;
	left: -10000px;
	top: -10000px;
	z-index: -1;
	opacity: 0;
	pointer-events: none;
}
.crop-mask {
	position: fixed;
	left: 0;
	right: 0;
	top: 0;
	bottom: 0;
	z-index: 9999;
	background: #000000;
	display: flex;
	flex-direction: column;
	align-items: center;
	box-sizing: border-box;
	padding: 0 0 28rpx;
}

.crop-header {
	width: 100%;
	height: 72rpx;
	display: flex;
	align-items: center;
	justify-content: center;
	opacity: 0;
}

.crop-title {
	font-size: 32rpx;
	color: #ffffff;
	font-weight: 600;
}

.crop-stage {
	position: relative;
	overflow: hidden;
	background: #000000;
	touch-action: none;
	margin-top: 140rpx;
}

.crop-image-wrap {
	position: absolute;
	left: 0;
	top: 0;
	will-change: transform;
	overflow: visible;
}

.crop-image {
	position: absolute;
	left: 0;
	top: 0;
	will-change: transform;
}

.watermark-preview {
	position: absolute;
	z-index: 4;
	display: flex;
	flex-direction: column;
	pointer-events: none;
}

.watermark-preview-line {
	font-size: 14px;
	line-height: 22px;
	color: #ffffff;
	text-shadow: 0 1px 2px rgba(0, 0, 0, 0.75);
}

.crop-shadow {
	position: absolute;
	background: rgba(0, 0, 0, 0.62);
	z-index: 2;
}

.crop-box {
	position: absolute;
	z-index: 3;
	box-sizing: border-box;
	border: 2px solid #ffffff;
	box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
}

.grid-line {
	position: absolute;
	background: rgba(255, 255, 255, 0.22);
}

.grid-line-v {
	top: 0;
	bottom: 0;
	width: 1px;
}

.grid-line-v1 {
	left: 33.333%;
}

.grid-line-v2 {
	left: 66.666%;
}

.grid-line-h {
	left: 0;
	right: 0;
	height: 1px;
}

.grid-line-h1 {
	top: 33.333%;
}

.grid-line-h2 {
	top: 66.666%;
}

.resize-handle {
	position: absolute;
	z-index: 4;
	width: 44rpx;
	height: 44rpx;
	box-sizing: border-box;
}

.resize-handle::after {
	content: '';
	position: absolute;
	left: 50%;
	top: 50%;
	width: 30rpx;
	height: 30rpx;
	margin-left: -15rpx;
	margin-top: -15rpx;
	border-color: #ffffff;
	border-style: solid;
	box-sizing: border-box;
}

.resize-handle-nw {
	left: -24rpx;
	top: -24rpx;
}

.resize-handle-nw::after {
	border-width: 8rpx 0 0 8rpx;
}

.resize-handle-n {
	left: 50%;
	top: -24rpx;
	margin-left: -22rpx;
}

.resize-handle-n::after {
	width: 40rpx;
	height: 8rpx;
	margin-left: -20rpx;
	margin-top: -4rpx;
	border-width: 0;
	background: #ffffff;
}

.resize-handle-ne {
	right: -24rpx;
	top: -24rpx;
}

.resize-handle-ne::after {
	border-width: 8rpx 8rpx 0 0;
}

.resize-handle-e {
	right: -24rpx;
	top: 50%;
	margin-top: -22rpx;
}

.resize-handle-e::after {
	width: 8rpx;
	height: 40rpx;
	margin-left: -4rpx;
	margin-top: -20rpx;
	border-width: 0;
	background: #ffffff;
}

.resize-handle-se {
	right: -24rpx;
	bottom: -24rpx;
}

.resize-handle-se::after {
	border-width: 0 8rpx 8rpx 0;
}

.resize-handle-s {
	left: 50%;
	bottom: -24rpx;
	margin-left: -22rpx;
}

.resize-handle-s::after {
	width: 40rpx;
	height: 8rpx;
	margin-left: -20rpx;
	margin-top: -4rpx;
	border-width: 0;
	background: #ffffff;
}

.resize-handle-sw {
	left: -24rpx;
	bottom: -24rpx;
}

.resize-handle-sw::after {
	border-width: 0 0 8rpx 8rpx;
}

.resize-handle-w {
	left: -24rpx;
	top: 50%;
	margin-top: -22rpx;
}

.resize-handle-w::after {
	width: 8rpx;
	height: 40rpx;
	margin-left: -4rpx;
	margin-top: -20rpx;
	border-width: 0;
	background: #ffffff;
}

.crop-tools {
	width: 100%;
	height: 190rpx;
	margin-top: auto;
	display: flex;
	align-items: center;
	justify-content: space-between;
	box-sizing: border-box;
	padding: 0 56rpx;
	border-bottom: 1px solid rgba(255, 255, 255, 0.14);
}

.tool-btn {
	min-width: 120rpx;
	height: 96rpx;
	padding: 0;
	margin: 0;
	background: transparent;
	color: #ffffff;
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	font-size: 26rpx;
	line-height: 1.2;
}

.tool-btn::after,
.crop-btn::after {
	border: 0;
}

.tool-btn.disabled {
	color: rgba(255, 255, 255, 0.28);
}

.tool-icon {
	font-size: 54rpx;
	line-height: 1;
}

.tool-text {
	margin-top: 10rpx;
}

.crop-actions {
	width: 100%;
	height: 132rpx;
	display: flex;
	align-items: center;
	justify-content: space-between;
	box-sizing: border-box;
	padding: 0 48rpx;
}

.crop-btn {
	width: 160rpx;
	height: 92rpx;
	line-height: 92rpx;
	border-radius: 0;
	font-size: 34rpx;
	padding: 0;
	margin: 0;
	background: transparent;
	color: #ffffff;
	text-align: center;
}

.crop-btn-cancel {
	text-align: left;
}

.crop-btn-confirm {
	text-align: right;
}

</style>
相关推荐
李纲明12 小时前
微信小程序 getSystemInfo 提示已废弃、屏幕高度算错胶囊还对不齐?getWindowInfo 和 getDeviceInfo 怎么接才稳
javascript·微信小程序·小程序·小程序开发·devdiy
deli00712 小时前
从一句反馈到一次升级:Mystudy 的自进化实践
前端
Felven13 小时前
C. Brr Brrr Patapim
c语言·开发语言
FungLeo13 小时前
成为全栈·Vue 管理后台篇·用 fetch 建一层可控的请求内核
前端·javascript·vue.js·http·fetch·formdata·接口封装
用户842981424181013 小时前
ByteJS,让重要的 JavaScript 以字节码运行。
javascript
路溪非溪14 小时前
python标准库使用总结
开发语言·python
FungLeo15 小时前
成为全栈·Vue 管理后台篇·Vue Router如何守住多角色后台边界
前端·javascript·vue.js·rbac·vue router·路由守卫·前端权限
FungLeo15 小时前
成为全栈·Vue 管理后台篇·分页只显示第一页:从接口total 定位到前端修复
前端·vue.js·状态模式·分页·naive ui·服务端分页·前端问题排查
ZhengEnCi15 小时前
苹果滑动为什么有「肉感」:橡皮筋越界阻尼与弹簧动画的物理原理
前端
Moment15 小时前
不会后期的我,发现了一个开源胶片神器😍😍😍
前端·后端·开源