cropper.vue 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956
  1. <script setup lang="ts">
  2. import { onMounted, onUnmounted, ref, watch } from 'vue';
  3. // 定义组件参数
  4. const props = defineProps<{
  5. /** 裁剪比例 格式如 '1:1', '16:9', '3:4' 等(非必填) */
  6. aspectRatio?: string;
  7. /** 容器高度(默认400) */
  8. height?: number;
  9. /** 图片地址 */
  10. img: string;
  11. /** 容器宽度(默认500) */
  12. width?: number;
  13. }>();
  14. const CROPPER_CONSTANTS = {
  15. MIN_WIDTH: 60 as const,
  16. MIN_HEIGHT: 60 as const,
  17. DEFAULT_WIDTH: 500 as const,
  18. DEFAULT_HEIGHT: 400 as const,
  19. PADDING_RATIO: 0.1 as const,
  20. MAX_PADDING: 50 as const,
  21. } as const;
  22. type Point = [number, number]; // [clientX, clientY]
  23. type Dimension = [number, number, number, number]; // [top, right, bottom, left]
  24. // 拖拽点类型
  25. type DragAction =
  26. | 'bottom'
  27. | 'bottom-left'
  28. | 'bottom-right'
  29. | 'left'
  30. | 'move'
  31. | 'right'
  32. | 'top'
  33. | 'top-left'
  34. | 'top-right';
  35. // DOM 引用
  36. const containerRef = ref<HTMLDivElement | null>(null);
  37. const bgImageRef = ref<HTMLImageElement | null>(null);
  38. const maskRef = ref<HTMLDivElement | null>(null);
  39. const maskViewRef = ref<HTMLDivElement | null>(null);
  40. const cropperRef = ref<HTMLDivElement | null>(null);
  41. const cropperViewRef = ref<HTMLDivElement | null>(null);
  42. // 响应式数据
  43. const isCropperVisible = ref<boolean>(false);
  44. const validAspectRatio = ref<null | number>(null); // 有效比例值(null表示无固定比例)
  45. const containerWidth = ref<number>(
  46. props.width ?? CROPPER_CONSTANTS.DEFAULT_WIDTH,
  47. );
  48. const containerHeight = ref<number>(
  49. props.height ?? CROPPER_CONSTANTS.DEFAULT_HEIGHT,
  50. );
  51. // 裁剪区域尺寸(top, right, bottom, left)
  52. const currentDimension = ref<Dimension>([50, 50, 50, 50]);
  53. const initDimension = ref<Dimension>([50, 50, 50, 50]);
  54. // 拖拽状态
  55. const dragging = ref<boolean>(false);
  56. const startPoint = ref<Point>([0, 0]);
  57. const startDimension = ref<Dimension>([0, 0, 0, 0]);
  58. const direction = ref<Dimension>([0, 0, 0, 0]);
  59. const moving = ref<boolean>(false);
  60. /**
  61. * 计算图片的适配尺寸,保证完整显示且不超过最大宽高限制
  62. */
  63. const calculateImageFitSize = () => {
  64. if (!bgImageRef.value) return;
  65. // 获取图片原始尺寸
  66. const imgWidth = bgImageRef.value.naturalWidth;
  67. const imgHeight = bgImageRef.value.naturalHeight;
  68. if (imgWidth === 0 || imgHeight === 0) return;
  69. // 计算缩放比例(使用传入的width/height,默认500/400)
  70. const widthRatio =
  71. (props.width ?? CROPPER_CONSTANTS.DEFAULT_WIDTH) / imgWidth;
  72. const heightRatio =
  73. (props.height ?? CROPPER_CONSTANTS.DEFAULT_HEIGHT) / imgHeight;
  74. const scaleRatio = Math.min(widthRatio, heightRatio, 1); // 不放大图片,只缩小
  75. // 计算适配后的容器尺寸
  76. const fitWidth = Math.floor(imgWidth * scaleRatio);
  77. const fitHeight = Math.floor(imgHeight * scaleRatio);
  78. containerWidth.value = fitWidth;
  79. containerHeight.value = fitHeight;
  80. // 重置裁剪框初始尺寸(基于新的容器尺寸)
  81. const padding = Math.min(
  82. CROPPER_CONSTANTS.MAX_PADDING,
  83. Math.floor(fitWidth * CROPPER_CONSTANTS.PADDING_RATIO),
  84. Math.floor(fitHeight * CROPPER_CONSTANTS.PADDING_RATIO),
  85. );
  86. initDimension.value = [padding, padding, padding, padding];
  87. currentDimension.value = [padding, padding, padding, padding];
  88. };
  89. /**
  90. * 验证并解析比例字符串
  91. * @returns {number|null} 比例值 (width/height),解析失败返回null
  92. */
  93. const parseAndValidateAspectRatio = (): null | number => {
  94. // 如果未传入比例参数,直接返回null
  95. if (!props.aspectRatio) {
  96. return null;
  97. }
  98. // 验证比例格式
  99. const ratioRegex = /^[1-9]\d*:[1-9]\d*$/;
  100. if (!ratioRegex.test(props.aspectRatio)) {
  101. console.warn('裁剪比例格式错误,应为 "数字:数字" 格式,如 "16:9"');
  102. return null;
  103. }
  104. // 解析比例
  105. const [width, height] = props.aspectRatio.split(':').map(Number);
  106. // 验证解析结果有效性
  107. if (Number.isNaN(width) || Number.isNaN(height) || !width || !height) {
  108. console.warn('裁剪比例解析失败,宽高必须为正整数');
  109. return null;
  110. }
  111. return width / height;
  112. };
  113. /**
  114. * 设置裁剪区域尺寸
  115. * @param {Dimension} dimension - [top, right, bottom, left]
  116. */
  117. const setDimension = (dimension: Dimension) => {
  118. currentDimension.value = [...dimension];
  119. if (maskViewRef.value) {
  120. maskViewRef.value.style.clipPath = `inset(${dimension[0]}px ${dimension[1]}px ${dimension[2]}px ${dimension[3]}px)`;
  121. }
  122. };
  123. /**
  124. * 调整裁剪区域至指定比例
  125. */
  126. const adjustCropperToAspectRatio = () => {
  127. if (!cropperRef.value) return;
  128. // 验证并解析比例
  129. validAspectRatio.value = parseAndValidateAspectRatio();
  130. // 如果无有效比例,使用初始尺寸,不强制固定比例
  131. if (validAspectRatio.value === null) {
  132. setDimension(initDimension.value);
  133. return;
  134. }
  135. // 有有效比例,按比例调整裁剪框
  136. const ratio = validAspectRatio.value;
  137. const containerWidthVal = containerWidth.value;
  138. const containerHeightVal = containerHeight.value;
  139. // 根据比例计算裁剪框尺寸
  140. let newHeight: number, newWidth: number;
  141. // 先按宽度优先计算
  142. newWidth = containerWidthVal;
  143. newHeight = newWidth / ratio;
  144. // 如果高度超出容器,按高度优先计算
  145. if (newHeight > containerHeightVal) {
  146. newHeight = containerHeightVal;
  147. newWidth = newHeight * ratio;
  148. }
  149. // 居中显示
  150. const leftRight = (containerWidthVal - newWidth) / 2;
  151. const topBottom = (containerHeightVal - newHeight) / 2;
  152. const newDimension: Dimension = [topBottom, leftRight, topBottom, leftRight];
  153. setDimension(newDimension);
  154. };
  155. /**
  156. * 创建裁剪器
  157. */
  158. const createCropper = () => {
  159. // 计算图片适配尺寸
  160. calculateImageFitSize();
  161. isCropperVisible.value = true;
  162. adjustCropperToAspectRatio();
  163. };
  164. /**
  165. * 处理鼠标按下事件
  166. * @param {MouseEvent} e - 鼠标事件
  167. * @param {DragAction} action - 操作类型
  168. */
  169. const handleMouseDown = (e: MouseEvent, action: DragAction) => {
  170. dragging.value = true;
  171. startPoint.value = [e.clientX, e.clientY];
  172. startDimension.value = [...currentDimension.value];
  173. direction.value = [0, 0, 0, 0];
  174. moving.value = false;
  175. // 处理移动
  176. if (action === 'move') {
  177. direction.value[0] = 1;
  178. direction.value[2] = -1;
  179. direction.value[3] = 1;
  180. direction.value[1] = -1;
  181. moving.value = true;
  182. return;
  183. }
  184. // 处理拖拽方向
  185. switch (action) {
  186. case 'bottom': {
  187. direction.value[2] = -1;
  188. break;
  189. }
  190. case 'bottom-left': {
  191. direction.value[2] = -1;
  192. direction.value[3] = 1;
  193. break;
  194. }
  195. case 'bottom-right': {
  196. direction.value[2] = -1;
  197. direction.value[1] = -1;
  198. break;
  199. }
  200. case 'left': {
  201. direction.value[3] = 1;
  202. break;
  203. }
  204. case 'right': {
  205. direction.value[1] = -1;
  206. break;
  207. }
  208. case 'top': {
  209. direction.value[0] = 1;
  210. break;
  211. }
  212. case 'top-left': {
  213. direction.value[0] = 1;
  214. direction.value[3] = 1;
  215. break;
  216. }
  217. case 'top-right': {
  218. direction.value[0] = 1;
  219. direction.value[1] = -1;
  220. break;
  221. }
  222. }
  223. };
  224. /**
  225. * 处理鼠标移动事件
  226. * @param {MouseEvent} e - 鼠标事件
  227. */
  228. const handleMouseMove = (e: MouseEvent) => {
  229. if (!dragging.value || !cropperRef.value) return;
  230. const { clientX, clientY } = e;
  231. const diffX = clientX - startPoint.value[0];
  232. const diffY = clientY - startPoint.value[1];
  233. // 处理移动裁剪框
  234. if (moving.value) {
  235. handleMoveCropBox(diffX, diffY);
  236. return;
  237. }
  238. // 无有效比例
  239. if (validAspectRatio.value === null) {
  240. handleFreeAspectResize(diffX, diffY);
  241. } else {
  242. handleFixedAspectResize(diffX, diffY);
  243. }
  244. };
  245. const handleMoveCropBox = (diffX: number, diffY: number) => {
  246. const newDimension = [...startDimension.value] as Dimension;
  247. // 计算临时偏移后的位置
  248. const tempTop = startDimension.value[0] + diffY;
  249. const tempLeft = startDimension.value[3] + diffX;
  250. // 计算裁剪框的固定尺寸
  251. const cropWidth =
  252. containerWidth.value - startDimension.value[3] - startDimension.value[1];
  253. const cropHeight =
  254. containerHeight.value - startDimension.value[0] - startDimension.value[2];
  255. // 边界限制:确保裁剪框完全在容器内,且尺寸不变
  256. // 顶部边界:top >= 0,且 bottom = 容器高度 - top - 裁剪高度 >= 0
  257. newDimension[0] = Math.max(
  258. 0,
  259. Math.min(tempTop, containerHeight.value - cropHeight),
  260. );
  261. // 底部边界:bottom = 容器高度 - top - 裁剪高度(由top推导,无需额外计算)
  262. newDimension[2] = containerHeight.value - newDimension[0] - cropHeight;
  263. // 左侧边界:left >= 0,且 right = 容器宽度 - left - 裁剪宽度 >= 0
  264. newDimension[3] = Math.max(
  265. 0,
  266. Math.min(tempLeft, containerWidth.value - cropWidth),
  267. );
  268. // 右侧边界:right = 容器宽度 - left - 裁剪宽度(由left推导,无需额外计算)
  269. newDimension[1] = containerWidth.value - newDimension[3] - cropWidth;
  270. // 强制保证尺寸不变(兜底)
  271. const finalWidth = containerWidth.value - newDimension[3] - newDimension[1];
  272. const finalHeight = containerHeight.value - newDimension[0] - newDimension[2];
  273. if (finalWidth !== cropWidth) {
  274. newDimension[1] = containerWidth.value - newDimension[3] - cropWidth;
  275. }
  276. if (finalHeight !== cropHeight) {
  277. newDimension[2] = containerHeight.value - newDimension[0] - cropHeight;
  278. }
  279. // 更新裁剪区域(仅位置变化,尺寸/比例完全不变)
  280. setDimension(newDimension);
  281. };
  282. const handleFreeAspectResize = (diffX: number, diffY: number) => {
  283. const cropperWidth = containerWidth.value;
  284. const cropperHeight = containerHeight.value;
  285. const currentDimensionNew: Dimension = [0, 0, 0, 0];
  286. // 计算新的尺寸,确保不小于最小值
  287. currentDimensionNew[0] = Math.min(
  288. Math.max(startDimension.value[0] + direction.value[0] * diffY, 0),
  289. cropperHeight - CROPPER_CONSTANTS.MIN_HEIGHT,
  290. );
  291. currentDimensionNew[1] = Math.min(
  292. Math.max(startDimension.value[1] + direction.value[1] * diffX, 0),
  293. cropperWidth - CROPPER_CONSTANTS.MIN_WIDTH,
  294. );
  295. currentDimensionNew[2] = Math.min(
  296. Math.max(startDimension.value[2] + direction.value[2] * diffY, 0),
  297. cropperHeight - CROPPER_CONSTANTS.MIN_HEIGHT,
  298. );
  299. currentDimensionNew[3] = Math.min(
  300. Math.max(startDimension.value[3] + direction.value[3] * diffX, 0),
  301. cropperWidth - CROPPER_CONSTANTS.MIN_WIDTH,
  302. );
  303. // 确保裁剪区域宽度和高度不小于最小值
  304. const newWidth =
  305. cropperWidth - currentDimensionNew[3] - currentDimensionNew[1];
  306. const newHeight =
  307. cropperHeight - currentDimensionNew[0] - currentDimensionNew[2];
  308. if (newWidth < CROPPER_CONSTANTS.MIN_WIDTH) {
  309. if (direction.value[3] === 1) {
  310. currentDimensionNew[3] =
  311. cropperWidth - currentDimensionNew[1] - CROPPER_CONSTANTS.MIN_WIDTH;
  312. } else {
  313. currentDimensionNew[1] =
  314. cropperWidth - currentDimensionNew[3] - CROPPER_CONSTANTS.MIN_WIDTH;
  315. }
  316. }
  317. if (newHeight < CROPPER_CONSTANTS.MIN_HEIGHT) {
  318. if (direction.value[0] === 1) {
  319. currentDimensionNew[0] =
  320. cropperHeight - currentDimensionNew[2] - CROPPER_CONSTANTS.MIN_HEIGHT;
  321. } else {
  322. currentDimensionNew[2] =
  323. cropperHeight - currentDimensionNew[0] - CROPPER_CONSTANTS.MIN_HEIGHT;
  324. }
  325. }
  326. setDimension(currentDimensionNew);
  327. };
  328. const handleFixedAspectResize = (diffX: number, diffY: number) => {
  329. if (validAspectRatio.value === null) return;
  330. const cropperWidth = containerWidth.value;
  331. const cropperHeight = containerHeight.value;
  332. // 有有效比例 - 固定比例裁剪
  333. const ratio = validAspectRatio.value;
  334. const currentWidth =
  335. cropperWidth - startDimension.value[3] - startDimension.value[1];
  336. const currentHeight =
  337. cropperHeight - startDimension.value[0] - startDimension.value[2];
  338. let newHeight: number, newWidth: number;
  339. let widthChange = 0;
  340. let heightChange = 0;
  341. // 计算宽度/高度变化量
  342. if (direction.value[3] === 1) widthChange = -diffX;
  343. else if (direction.value[1] === -1) widthChange = diffX;
  344. if (direction.value[0] === 1) heightChange = -diffY;
  345. else if (direction.value[2] === -1) heightChange = diffY;
  346. const isCornerDrag =
  347. (direction.value[3] === 1 || direction.value[1] === -1) &&
  348. (direction.value[0] === 1 || direction.value[2] === -1);
  349. // 计算新尺寸
  350. if (isCornerDrag) {
  351. if (Math.abs(widthChange) > Math.abs(heightChange)) {
  352. newWidth = Math.max(
  353. CROPPER_CONSTANTS.MIN_WIDTH,
  354. currentWidth + widthChange,
  355. );
  356. newHeight = newWidth / ratio;
  357. } else {
  358. newHeight = Math.max(
  359. CROPPER_CONSTANTS.MIN_HEIGHT,
  360. currentHeight + heightChange,
  361. );
  362. newWidth = newHeight * ratio;
  363. }
  364. } else {
  365. if (direction.value[3] === 1 || direction.value[1] === -1) {
  366. newWidth = Math.max(
  367. CROPPER_CONSTANTS.MIN_WIDTH,
  368. currentWidth + widthChange,
  369. );
  370. newHeight = newWidth / ratio;
  371. } else {
  372. newHeight = Math.max(
  373. CROPPER_CONSTANTS.MIN_HEIGHT,
  374. currentHeight + heightChange,
  375. );
  376. newWidth = newHeight * ratio;
  377. }
  378. }
  379. // 限制最大尺寸
  380. const maxWidth = cropperWidth;
  381. const maxHeight = cropperHeight;
  382. if (newWidth > maxWidth) {
  383. newWidth = maxWidth;
  384. newHeight = newWidth / ratio;
  385. }
  386. if (newHeight > maxHeight) {
  387. newHeight = maxHeight;
  388. newWidth = newHeight * ratio;
  389. }
  390. // 计算新的位置
  391. let newLeft = startDimension.value[3];
  392. let newTop = startDimension.value[0];
  393. let newRight = startDimension.value[1];
  394. let newBottom = startDimension.value[2];
  395. // 根据拖拽方向调整位置
  396. if (direction.value[3] === 1) {
  397. newLeft = cropperWidth - newWidth - startDimension.value[1];
  398. } else if (direction.value[1] === -1) {
  399. newRight = cropperWidth - newWidth - startDimension.value[3];
  400. } else if (!isCornerDrag) {
  401. // 居中调整
  402. const currentHorizontalCenter = startDimension.value[3] + currentWidth / 2;
  403. newLeft = Math.max(
  404. 0,
  405. Math.min(cropperWidth - newWidth, currentHorizontalCenter - newWidth / 2),
  406. );
  407. newRight = cropperWidth - newWidth - newLeft;
  408. }
  409. if (direction.value[0] === 1) {
  410. newTop = cropperHeight - newHeight - startDimension.value[2];
  411. } else if (direction.value[2] === -1) {
  412. newBottom = cropperHeight - newHeight - startDimension.value[0];
  413. } else if (!isCornerDrag) {
  414. // 居中调整
  415. const currentVerticalCenter = startDimension.value[0] + currentHeight / 2;
  416. newTop = Math.max(
  417. 0,
  418. Math.min(
  419. cropperHeight - newHeight,
  420. currentVerticalCenter - newHeight / 2,
  421. ),
  422. );
  423. newBottom = cropperHeight - newHeight - newTop;
  424. }
  425. // 边界检查
  426. newLeft = Math.max(0, newLeft);
  427. newTop = Math.max(0, newTop);
  428. newRight = Math.max(0, newRight);
  429. newBottom = Math.max(0, newBottom);
  430. const newDimension: Dimension = [newTop, newRight, newBottom, newLeft];
  431. setDimension(newDimension);
  432. };
  433. /**
  434. * 处理鼠标抬起事件
  435. */
  436. const handleMouseUp = () => {
  437. dragging.value = false;
  438. moving.value = false;
  439. direction.value = [0, 0, 0, 0];
  440. };
  441. /**
  442. * 处理图片加载完成
  443. */
  444. const handleImageLoad = () => {
  445. createCropper();
  446. };
  447. /**
  448. * 裁剪图片
  449. * @param {'image/jpeg' | 'image/png'} format - 输出图片格式
  450. * @param {number} quality - 压缩质量(0-1)
  451. * @param {number} targetWidth - 目标宽度(可选,不传则为原始裁剪宽度)
  452. * @param {number} targetHeight - 目标高度(可选,不传则为原始裁剪高度)
  453. */
  454. const getCropImage = async (
  455. format: 'image/jpeg' | 'image/png' = 'image/jpeg',
  456. quality: number = 0.92,
  457. targetWidth?: number,
  458. targetHeight?: number,
  459. ): Promise<string | undefined> => {
  460. if (!props.img || !bgImageRef.value || !containerRef.value) return;
  461. // 创建临时图片对象获取原始尺寸
  462. const tempImg = new Image();
  463. // Only set crossOrigin for cross-origin URLs that need CORS
  464. if (props.img.startsWith('http://') || props.img.startsWith('https://')) {
  465. try {
  466. const url = new URL(props.img);
  467. if (url.origin !== location.origin) {
  468. tempImg.crossOrigin = 'anonymous';
  469. }
  470. } catch {
  471. // Invalid URL, proceed without crossOrigin
  472. }
  473. }
  474. // 等待临时图片加载完成
  475. await new Promise<void>((resolve, reject) => {
  476. const timeout = setTimeout(() => {
  477. tempImg.removeEventListener('load', handleLoad);
  478. tempImg.removeEventListener('error', handleError);
  479. reject(new Error('图片加载超时'));
  480. }, 10_000);
  481. const handleLoad = () => {
  482. clearTimeout(timeout);
  483. tempImg.removeEventListener('load', handleLoad);
  484. tempImg.removeEventListener('error', handleError);
  485. resolve();
  486. };
  487. const handleError = (err: ErrorEvent) => {
  488. clearTimeout(timeout);
  489. tempImg.removeEventListener('load', handleLoad);
  490. tempImg.removeEventListener('error', handleError);
  491. reject(new Error(`图片加载失败: ${err.message}`));
  492. };
  493. tempImg.addEventListener('load', handleLoad);
  494. tempImg.addEventListener('error', handleError);
  495. tempImg.src = props.img;
  496. });
  497. const containerRect = containerRef.value.getBoundingClientRect();
  498. const imgRect = bgImageRef.value.getBoundingClientRect();
  499. // 1. 计算图片在容器内的渲染参数
  500. const containerWidth = containerRect.width;
  501. const containerHeight = containerRect.height;
  502. const renderedImgWidth = imgRect.width;
  503. const renderedImgHeight = imgRect.height;
  504. const imgOffsetX = (containerWidth - renderedImgWidth) / 2;
  505. const imgOffsetY = (containerHeight - renderedImgHeight) / 2;
  506. // 2. 计算裁剪框在容器内的实际坐标
  507. const [cropTop, cropRight, cropBottom, cropLeft] = currentDimension.value;
  508. const cropBoxWidth = containerWidth - cropLeft - cropRight;
  509. const cropBoxHeight = containerHeight - cropTop - cropBottom;
  510. // 3. 将裁剪框坐标转换为图片上的坐标(考虑图片偏移)
  511. const cropOnImgX = cropLeft - imgOffsetX;
  512. const cropOnImgY = cropTop - imgOffsetY;
  513. // 4. 计算渲染图片到原始图片的缩放比例(关键:保留原始像素)
  514. const scaleX = tempImg.width / renderedImgWidth;
  515. const scaleY = tempImg.height / renderedImgHeight;
  516. // 5. 映射到原始图片的裁剪区域(精确到原始像素)
  517. const originalCropX = Math.max(0, Math.floor(cropOnImgX * scaleX));
  518. const originalCropY = Math.max(0, Math.floor(cropOnImgY * scaleY));
  519. const originalCropWidth = Math.min(
  520. Math.floor(cropBoxWidth * scaleX),
  521. tempImg.width - originalCropX,
  522. );
  523. const originalCropHeight = Math.min(
  524. Math.floor(cropBoxHeight * scaleY),
  525. tempImg.height - originalCropY,
  526. );
  527. // 6. 处理高清屏适配(关键:解决Retina屏模糊)
  528. const dpr = window.devicePixelRatio || 1;
  529. // 最终画布尺寸(优先使用原始裁剪尺寸,或目标尺寸)
  530. const finalWidth = targetWidth || originalCropWidth;
  531. const finalHeight = targetHeight || originalCropHeight;
  532. // 创建画布(乘以设备像素比,保证高清)
  533. const canvas = document.createElement('canvas');
  534. const ctx = canvas.getContext('2d');
  535. if (!ctx) return;
  536. // 画布物理尺寸(适配高清屏)
  537. canvas.width = finalWidth * dpr;
  538. canvas.height = finalHeight * dpr;
  539. // 画布显示尺寸(视觉尺寸)
  540. canvas.style.width = `${finalWidth}px`;
  541. canvas.style.height = `${finalHeight}px`;
  542. // 缩放上下文(适配DPR)
  543. ctx.scale(dpr, dpr);
  544. // 7. 绘制裁剪后的图片(使用原始像素绘制,保证清晰度)
  545. ctx.drawImage(
  546. tempImg,
  547. originalCropX, // 原始图片裁剪起始X(精确像素)
  548. originalCropY, // 原始图片裁剪起始Y(精确像素)
  549. originalCropWidth, // 原始图片裁剪宽度(精确像素)
  550. originalCropHeight, // 原始图片裁剪高度(精确像素)
  551. 0, // 画布绘制起始X
  552. 0, // 画布绘制起始Y
  553. finalWidth, // 画布绘制宽度(目标尺寸)
  554. finalHeight, // 画布绘制高度(目标尺寸)
  555. );
  556. // 8. 导出图片(指定质量,平衡清晰度和体积)
  557. return canvas.toDataURL(format, quality);
  558. };
  559. // 监听比例变化,重新调整裁剪框
  560. watch(() => props.aspectRatio, adjustCropperToAspectRatio);
  561. // 监听width/height变化,重新计算尺寸
  562. watch([() => props.width, () => props.height], () => {
  563. calculateImageFitSize();
  564. adjustCropperToAspectRatio();
  565. });
  566. // 组件挂载时注册全局事件
  567. onMounted(() => {
  568. document.addEventListener('mousemove', handleMouseMove);
  569. document.addEventListener('mouseup', handleMouseUp);
  570. // 如果图片已经加载完成,手动触发创建裁剪器
  571. if (
  572. bgImageRef.value &&
  573. bgImageRef.value.complete &&
  574. bgImageRef.value.naturalWidth > 0
  575. ) {
  576. createCropper();
  577. }
  578. });
  579. // 组件卸载时清理
  580. onUnmounted(() => {
  581. document.removeEventListener('mousemove', handleMouseMove);
  582. document.removeEventListener('mouseup', handleMouseUp);
  583. });
  584. defineExpose({ getCropImage });
  585. </script>
  586. <template>
  587. <div
  588. :style="{
  589. width: `${width || CROPPER_CONSTANTS.DEFAULT_WIDTH}px`,
  590. height: `${height || CROPPER_CONSTANTS.DEFAULT_HEIGHT}px`,
  591. }"
  592. class="cropper-action-wrapper"
  593. >
  594. <div
  595. ref="containerRef"
  596. class="cropper-container"
  597. :style="{
  598. width: `${containerWidth}px`,
  599. height: `${containerHeight}px`,
  600. }"
  601. >
  602. <!-- 原图展示 - 自适应尺寸 -->
  603. <img
  604. ref="bgImageRef"
  605. class="cropper-image"
  606. :src="img"
  607. @load="handleImageLoad"
  608. :style="{
  609. maxWidth: '100%',
  610. maxHeight: '100%',
  611. objectFit: 'contain',
  612. }"
  613. alt="裁剪原图"
  614. />
  615. <!-- 遮罩层 -->
  616. <div
  617. ref="maskRef"
  618. class="cropper-mask"
  619. :style="{
  620. display: isCropperVisible ? 'block' : 'none',
  621. width: '100%',
  622. height: '100%',
  623. }"
  624. >
  625. <div
  626. ref="maskViewRef"
  627. class="cropper-mask-view"
  628. :style="{
  629. backgroundImage: `url(${img})`,
  630. backgroundSize: 'contain',
  631. backgroundPosition: 'center',
  632. backgroundRepeat: 'no-repeat',
  633. clipPath: `inset(${currentDimension[0]}px ${currentDimension[1]}px ${currentDimension[2]}px ${currentDimension[3]}px)`,
  634. width: '100%',
  635. height: '100%',
  636. }"
  637. ></div>
  638. </div>
  639. <!-- 裁剪框 -->
  640. <div
  641. ref="cropperRef"
  642. class="cropper-box"
  643. :style="{
  644. display: isCropperVisible ? 'block' : 'none',
  645. width: '100%',
  646. height: '100%',
  647. }"
  648. >
  649. <div
  650. ref="cropperViewRef"
  651. class="cropper-view"
  652. :style="{
  653. inset: `${currentDimension[0]}px ${currentDimension[1]}px ${currentDimension[2]}px ${currentDimension[3]}px`,
  654. }"
  655. >
  656. <!-- 裁剪框辅助线-->
  657. <span class="cropper-dashed-h"></span>
  658. <span class="cropper-dashed-v"></span>
  659. <!-- 裁剪框拖拽区域 -->
  660. <span
  661. class="cropper-move-area"
  662. @mousedown="handleMouseDown($event, 'move')"
  663. ></span>
  664. <!-- 边框线 -->
  665. <span class="cropper-line-e"></span>
  666. <span class="cropper-line-n"></span>
  667. <span class="cropper-line-w"></span>
  668. <span class="cropper-line-s"></span>
  669. <!-- 边角拖拽点 -->
  670. <span
  671. class="cropper-point cropper-point-ne"
  672. @mousedown="handleMouseDown($event, 'top-right')"
  673. >
  674. <span class="cropper-point-inner"></span>
  675. </span>
  676. <span
  677. class="cropper-point cropper-point-nw"
  678. @mousedown="handleMouseDown($event, 'top-left')"
  679. >
  680. <span class="cropper-point-inner"></span>
  681. </span>
  682. <span
  683. class="cropper-point cropper-point-sw"
  684. @mousedown="handleMouseDown($event, 'bottom-left')"
  685. >
  686. <span class="cropper-point-inner"></span>
  687. </span>
  688. <span
  689. class="cropper-point cropper-point-se"
  690. @mousedown="handleMouseDown($event, 'bottom-right')"
  691. >
  692. <span class="cropper-point-inner"></span>
  693. </span>
  694. <!-- 边中点拖拽点 -->
  695. <span
  696. class="cropper-point cropper-point-e"
  697. @mousedown="handleMouseDown($event, 'right')"
  698. >
  699. <span class="cropper-point-inner"></span>
  700. </span>
  701. <span
  702. class="cropper-point cropper-point-n"
  703. @mousedown="handleMouseDown($event, 'top')"
  704. >
  705. <span class="cropper-point-inner"></span>
  706. </span>
  707. <span
  708. class="cropper-point cropper-point-w"
  709. @mousedown="handleMouseDown($event, 'left')"
  710. >
  711. <span class="cropper-point-inner"></span>
  712. </span>
  713. <span
  714. class="cropper-point cropper-point-s"
  715. @mousedown="handleMouseDown($event, 'bottom')"
  716. >
  717. <span class="cropper-point-inner"></span>
  718. </span>
  719. </div>
  720. </div>
  721. </div>
  722. </div>
  723. </template>
  724. <style scoped>
  725. .cropper-action-wrapper {
  726. @apply box-border flex items-center justify-center;
  727. /* 马赛克背景 */
  728. background-image:
  729. linear-gradient(45deg, #ccc 25%, transparent 25%),
  730. linear-gradient(-45deg, #ccc 25%, transparent 25%),
  731. linear-gradient(45deg, transparent 75%, #ccc 75%),
  732. linear-gradient(-45deg, transparent 75%, #ccc 75%);
  733. background-size: 20px 20px;
  734. background-position:
  735. 0 0,
  736. 0 10px,
  737. 10px -10px,
  738. -10px 0;
  739. background-color: transparent;
  740. }
  741. .cropper-container {
  742. @apply relative;
  743. }
  744. .cropper-image {
  745. @apply block;
  746. }
  747. /* 遮罩层 */
  748. .cropper-mask {
  749. @apply absolute left-0 top-0 bg-black/50;
  750. }
  751. .cropper-mask-view {
  752. @apply absolute left-0 top-0;
  753. }
  754. /* 裁剪框 */
  755. .cropper-box {
  756. @apply absolute left-0 top-0 z-10;
  757. }
  758. .cropper-view {
  759. @apply absolute bottom-0 left-0 right-0 top-0 select-none outline outline-1 outline-blue-500;
  760. }
  761. /* 裁剪框辅助线 */
  762. .cropper-dashed-h {
  763. @apply absolute left-0 top-1/3 block h-1/3 w-full border-b border-t border-dashed border-gray-200/50;
  764. }
  765. .cropper-dashed-v {
  766. @apply absolute left-1/3 top-0 block h-full w-1/3 border-l border-r border-dashed border-gray-200/50;
  767. }
  768. /* 裁剪框拖拽区域 */
  769. .cropper-move-area {
  770. @apply absolute left-0 top-0 block h-full w-full cursor-move bg-white/10;
  771. }
  772. /* 边框拖拽线 */
  773. .cropper-line-e,
  774. .cropper-line-n,
  775. .cropper-line-w,
  776. .cropper-line-s {
  777. @apply absolute block bg-blue-500/10;
  778. }
  779. .cropper-line-e {
  780. @apply right-[-3px] top-0 h-full w-1;
  781. }
  782. .cropper-line-n {
  783. @apply left-0 top-[-3px] h-1 w-full;
  784. }
  785. .cropper-line-w {
  786. @apply left-[-3px] top-0 h-full w-1;
  787. }
  788. .cropper-line-s {
  789. @apply bottom-[-3px] left-0 h-1 w-full;
  790. }
  791. /* 拖拽点 */
  792. .cropper-point {
  793. @apply absolute flex h-2 w-2 items-center justify-center bg-blue-500;
  794. }
  795. .cropper-point-inner {
  796. @apply block h-1.5 w-1.5 bg-white;
  797. }
  798. /* 边角拖拽点位置和光标 */
  799. .cropper-point-ne {
  800. @apply right-[-5px] top-[-5px] cursor-ne-resize;
  801. }
  802. .cropper-point-nw {
  803. @apply left-[-5px] top-[-5px] cursor-nw-resize;
  804. }
  805. .cropper-point-sw {
  806. @apply bottom-[-5px] left-[-5px] cursor-sw-resize;
  807. }
  808. .cropper-point-se {
  809. @apply bottom-[-5px] right-[-5px] cursor-se-resize;
  810. }
  811. /* 边中点拖拽点位置和光标 */
  812. .cropper-point-e {
  813. @apply right-[-5px] top-1/2 -mt-1 cursor-e-resize;
  814. }
  815. .cropper-point-n {
  816. @apply left-1/2 top-[-5px] -ml-1 cursor-n-resize;
  817. }
  818. .cropper-point-w {
  819. @apply left-[-5px] top-1/2 -mt-1 cursor-w-resize;
  820. }
  821. .cropper-point-s {
  822. @apply bottom-[-5px] left-1/2 -ml-1 cursor-s-resize;
  823. }
  824. </style>