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>
相关推荐
Dxy12393102161 小时前
HTML如何写鼠标事件
前端·html·计算机外设
Vallelonga1 小时前
Rust 中 unsafe 关键字的语义
开发语言·rust
AI砖家1 小时前
前端 JavaScript 异步处理全方案详解:从回调到 Observable
开发语言·前端·javascript
思麟呀2 小时前
C++工业级日志项目(七)日志器核心
linux·开发语言·c++·windows
2401_873479402 小时前
如何用IP离线库批量清洗订单IP,自动标注省市区?
开发语言·网络·python
用户713874229002 小时前
构建现代 Web 应用的令牌安全体系:Refresh Token Rotation、HttpOnly Cookie 与 Grace Period 全解析
前端
路光.2 小时前
uniapp中解决webview在app中调用,有过渡空白问题,增加过渡动效
uni-app·vue·app·uniapp
lcj25112 小时前
vector的基本使用 + 手搓成员变量 size capacity begin end operator[] reserve扩容 拷贝构造 赋值析构
开发语言·c++·笔记·面试
柒和远方2 小时前
每日一学V010: 从 Python 回到前端:一个 AI Native 开发者的 JavaScript 底层基础补全
javascript