cropper.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981
  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 {'blob' | 'base64'} outputType - 输出类型
  452. * @param {number} targetWidth - 目标宽度(可选,不传则为原始裁剪宽度)
  453. * @param {number} targetHeight - 目标高度(可选,不传则为原始裁剪高度)
  454. */
  455. const getCropImage = async (
  456. format: 'image/jpeg' | 'image/png' = 'image/jpeg',
  457. quality: number = 0.92,
  458. outputType: 'base64' | 'blob' = 'blob',
  459. targetWidth?: number,
  460. targetHeight?: number,
  461. ): Promise<Blob | string | undefined> => {
  462. if (!props.img || !bgImageRef.value || !containerRef.value) return;
  463. // 质量参数边界修正:强制限制在 0-1 区间,防止传入非法值报错
  464. const validQuality = Math.max(0, Math.min(1, quality));
  465. // 创建临时图片对象获取原始尺寸
  466. const tempImg = new Image();
  467. // 跨域图片处理:仅对非同源的网络图片设置跨域匿名
  468. if (props.img.startsWith('http://') || props.img.startsWith('https://')) {
  469. try {
  470. const url = new URL(props.img);
  471. if (url.origin !== location.origin) {
  472. tempImg.crossOrigin = 'anonymous';
  473. }
  474. } catch {
  475. // Invalid URL,跳过跨域配置,不中断执行
  476. }
  477. }
  478. // 等待临时图片加载完成
  479. await new Promise<void>((resolve, reject) => {
  480. const timeout = setTimeout(() => {
  481. tempImg.removeEventListener('load', handleLoad);
  482. tempImg.removeEventListener('error', handleError);
  483. reject(new Error('图片加载超时,超时时间10秒'));
  484. }, 10_000);
  485. const handleLoad = () => {
  486. clearTimeout(timeout);
  487. tempImg.removeEventListener('load', handleLoad);
  488. tempImg.removeEventListener('error', handleError);
  489. resolve();
  490. };
  491. const handleError = (err: ErrorEvent) => {
  492. clearTimeout(timeout);
  493. tempImg.removeEventListener('load', handleLoad);
  494. tempImg.removeEventListener('error', handleError);
  495. reject(new Error(`图片加载失败: ${err.message}`));
  496. };
  497. tempImg.addEventListener('load', handleLoad);
  498. tempImg.addEventListener('error', handleError);
  499. tempImg.src = props.img;
  500. });
  501. const containerRect = containerRef.value.getBoundingClientRect();
  502. const imgRect = bgImageRef.value.getBoundingClientRect();
  503. // 1. 计算图片在容器内的渲染参数
  504. const containerWidth = containerRect.width;
  505. const containerHeight = containerRect.height;
  506. const renderedImgWidth = imgRect.width;
  507. const renderedImgHeight = imgRect.height;
  508. const imgOffsetX = (containerWidth - renderedImgWidth) / 2;
  509. const imgOffsetY = (containerHeight - renderedImgHeight) / 2;
  510. // 2. 计算裁剪框在容器内的实际坐标
  511. const [cropTop, cropRight, cropBottom, cropLeft] = currentDimension.value;
  512. const cropBoxWidth = containerWidth - cropLeft - cropRight;
  513. const cropBoxHeight = containerHeight - cropTop - cropBottom;
  514. // 3. 将裁剪框坐标转换为图片上的坐标(考虑图片偏移)
  515. const cropOnImgX = cropLeft - imgOffsetX;
  516. const cropOnImgY = cropTop - imgOffsetY;
  517. // 4. 计算渲染图片到原始图片的缩放比例(保留原始像素)
  518. const scaleX = tempImg.width / renderedImgWidth;
  519. const scaleY = tempImg.height / renderedImgHeight;
  520. // 5. 映射到原始图片的裁剪区域(精确到原始像素,防止越界)
  521. const originalCropX = Math.max(0, Math.floor(cropOnImgX * scaleX));
  522. const originalCropY = Math.max(0, Math.floor(cropOnImgY * scaleY));
  523. const originalCropWidth = Math.min(
  524. Math.floor(cropBoxWidth * scaleX),
  525. tempImg.width - originalCropX,
  526. );
  527. const originalCropHeight = Math.min(
  528. Math.floor(cropBoxHeight * scaleY),
  529. tempImg.height - originalCropY,
  530. );
  531. // 边界校验:裁剪尺寸非法则返回
  532. if (originalCropWidth <= 0 || originalCropHeight <= 0) return;
  533. // 6. 处理高清屏适配(解决Retina屏模糊)
  534. const dpr = window.devicePixelRatio || 1;
  535. // 最终画布尺寸(优先使用传入的目标尺寸,无则用原始裁剪尺寸)
  536. const finalWidth = targetWidth ? Math.max(1, targetWidth) : originalCropWidth;
  537. const finalHeight = targetHeight
  538. ? Math.max(1, targetHeight)
  539. : originalCropHeight;
  540. // 创建画布并获取绘制上下文
  541. const canvas = document.createElement('canvas');
  542. const ctx = canvas.getContext('2d');
  543. if (!ctx) return;
  544. // 画布物理尺寸(乘以设备像素比,保证高清无模糊)
  545. canvas.width = finalWidth * dpr;
  546. canvas.height = finalHeight * dpr;
  547. // 画布显示尺寸(视觉尺寸,和最终展示一致)
  548. canvas.style.width = `${finalWidth}px`;
  549. canvas.style.height = `${finalHeight}px`;
  550. // 缩放画布上下文,适配高清屏DPR
  551. ctx.scale(dpr, dpr);
  552. // 7. 绘制裁剪后的图片(使用原始像素绘制,保证清晰度)
  553. ctx.drawImage(
  554. tempImg,
  555. originalCropX, // 原始图片裁剪起始X(精确像素)
  556. originalCropY, // 原始图片裁剪起始Y(精确像素)
  557. originalCropWidth, // 原始图片裁剪宽度(精确像素)
  558. originalCropHeight, // 原始图片裁剪高度(精确像素)
  559. 0, // 画布绘制起始X
  560. 0, // 画布绘制起始Y
  561. finalWidth, // 画布绘制宽度(目标尺寸)
  562. finalHeight, // 画布绘制高度(目标尺寸)
  563. );
  564. try {
  565. return outputType === 'base64'
  566. ? canvas.toDataURL(format, validQuality)
  567. : new Promise<Blob>((resolve) => {
  568. canvas.toBlob(
  569. (blob) => {
  570. // 兜底:如果blob生成失败,返回空Blob(防止null)
  571. resolve(blob || new Blob([], { type: format }));
  572. },
  573. format,
  574. validQuality,
  575. );
  576. });
  577. } catch (error) {
  578. console.error('图片导出失败:', error);
  579. }
  580. };
  581. // 监听比例变化,重新调整裁剪框
  582. watch(() => props.aspectRatio, adjustCropperToAspectRatio);
  583. // 监听width/height变化,重新计算尺寸
  584. watch([() => props.width, () => props.height], () => {
  585. calculateImageFitSize();
  586. adjustCropperToAspectRatio();
  587. });
  588. // 组件挂载时注册全局事件
  589. onMounted(() => {
  590. document.addEventListener('mousemove', handleMouseMove);
  591. document.addEventListener('mouseup', handleMouseUp);
  592. // 如果图片已经加载完成,手动触发创建裁剪器
  593. if (
  594. bgImageRef.value &&
  595. bgImageRef.value.complete &&
  596. bgImageRef.value.naturalWidth > 0
  597. ) {
  598. createCropper();
  599. }
  600. });
  601. // 组件卸载时清理
  602. onUnmounted(() => {
  603. document.removeEventListener('mousemove', handleMouseMove);
  604. document.removeEventListener('mouseup', handleMouseUp);
  605. });
  606. defineExpose({ getCropImage });
  607. </script>
  608. <template>
  609. <div
  610. :style="{
  611. width: `${width || CROPPER_CONSTANTS.DEFAULT_WIDTH}px`,
  612. height: `${height || CROPPER_CONSTANTS.DEFAULT_HEIGHT}px`,
  613. }"
  614. class="cropper-action-wrapper"
  615. >
  616. <div
  617. ref="containerRef"
  618. class="cropper-container"
  619. :style="{
  620. width: `${containerWidth}px`,
  621. height: `${containerHeight}px`,
  622. }"
  623. >
  624. <!-- 原图展示 - 自适应尺寸 -->
  625. <img
  626. ref="bgImageRef"
  627. class="cropper-image"
  628. :src="img"
  629. @load="handleImageLoad"
  630. :style="{
  631. maxWidth: '100%',
  632. maxHeight: '100%',
  633. objectFit: 'contain',
  634. }"
  635. alt="裁剪原图"
  636. />
  637. <!-- 遮罩层 -->
  638. <div
  639. class="cropper-mask"
  640. :style="{
  641. display: isCropperVisible ? 'block' : 'none',
  642. width: '100%',
  643. height: '100%',
  644. }"
  645. >
  646. <div
  647. ref="maskViewRef"
  648. class="cropper-mask-view"
  649. :style="{
  650. backgroundImage: `url(${img})`,
  651. backgroundSize: 'contain',
  652. backgroundPosition: 'center',
  653. backgroundRepeat: 'no-repeat',
  654. clipPath: `inset(${currentDimension[0]}px ${currentDimension[1]}px ${currentDimension[2]}px ${currentDimension[3]}px)`,
  655. width: '100%',
  656. height: '100%',
  657. }"
  658. ></div>
  659. </div>
  660. <!-- 裁剪框 -->
  661. <div
  662. ref="cropperRef"
  663. class="cropper-box"
  664. :style="{
  665. display: isCropperVisible ? 'block' : 'none',
  666. width: '100%',
  667. height: '100%',
  668. }"
  669. >
  670. <div
  671. class="cropper-view"
  672. :style="{
  673. inset: `${currentDimension[0]}px ${currentDimension[1]}px ${currentDimension[2]}px ${currentDimension[3]}px`,
  674. }"
  675. >
  676. <!-- 裁剪框辅助线-->
  677. <span class="cropper-dashed-h"></span>
  678. <span class="cropper-dashed-v"></span>
  679. <!-- 裁剪框拖拽区域 -->
  680. <span
  681. class="cropper-move-area"
  682. @mousedown="handleMouseDown($event, 'move')"
  683. ></span>
  684. <!-- 边框线 -->
  685. <span class="cropper-line-e"></span>
  686. <span class="cropper-line-n"></span>
  687. <span class="cropper-line-w"></span>
  688. <span class="cropper-line-s"></span>
  689. <!-- 边角拖拽点 -->
  690. <span
  691. class="cropper-point cropper-point-ne"
  692. @mousedown="handleMouseDown($event, 'top-right')"
  693. >
  694. <span class="cropper-point-inner"></span>
  695. </span>
  696. <span
  697. class="cropper-point cropper-point-nw"
  698. @mousedown="handleMouseDown($event, 'top-left')"
  699. >
  700. <span class="cropper-point-inner"></span>
  701. </span>
  702. <span
  703. class="cropper-point cropper-point-sw"
  704. @mousedown="handleMouseDown($event, 'bottom-left')"
  705. >
  706. <span class="cropper-point-inner"></span>
  707. </span>
  708. <span
  709. class="cropper-point cropper-point-se"
  710. @mousedown="handleMouseDown($event, 'bottom-right')"
  711. >
  712. <span class="cropper-point-inner"></span>
  713. </span>
  714. <!-- 边中点拖拽点 -->
  715. <span
  716. class="cropper-point cropper-point-e"
  717. @mousedown="handleMouseDown($event, 'right')"
  718. >
  719. <span class="cropper-point-inner"></span>
  720. </span>
  721. <span
  722. class="cropper-point cropper-point-n"
  723. @mousedown="handleMouseDown($event, 'top')"
  724. >
  725. <span class="cropper-point-inner"></span>
  726. </span>
  727. <span
  728. class="cropper-point cropper-point-w"
  729. @mousedown="handleMouseDown($event, 'left')"
  730. >
  731. <span class="cropper-point-inner"></span>
  732. </span>
  733. <span
  734. class="cropper-point cropper-point-s"
  735. @mousedown="handleMouseDown($event, 'bottom')"
  736. >
  737. <span class="cropper-point-inner"></span>
  738. </span>
  739. </div>
  740. </div>
  741. </div>
  742. </div>
  743. </template>
  744. <style scoped>
  745. @reference "@vben-core/design/theme";
  746. .cropper-action-wrapper {
  747. @apply box-border flex items-center justify-center;
  748. background-color: transparent;
  749. /* 马赛克背景 */
  750. background-image:
  751. linear-gradient(45deg, #ccc 25%, transparent 25%),
  752. linear-gradient(-45deg, #ccc 25%, transparent 25%),
  753. linear-gradient(45deg, transparent 75%, #ccc 75%),
  754. linear-gradient(-45deg, transparent 75%, #ccc 75%);
  755. background-position:
  756. 0 0,
  757. 0 10px,
  758. 10px -10px,
  759. -10px 0;
  760. background-size: 20px 20px;
  761. }
  762. .cropper-container {
  763. @apply relative;
  764. }
  765. .cropper-image {
  766. @apply block;
  767. }
  768. /* 遮罩层 */
  769. .cropper-mask {
  770. @apply absolute top-0 left-0 bg-black/50;
  771. }
  772. .cropper-mask-view {
  773. @apply absolute top-0 left-0;
  774. }
  775. /* 裁剪框 */
  776. .cropper-box {
  777. @apply absolute top-0 left-0 z-10;
  778. }
  779. .cropper-view {
  780. @apply absolute top-0 right-0 bottom-0 left-0 outline-1 outline-blue-500 select-none;
  781. }
  782. /* 裁剪框辅助线 */
  783. .cropper-dashed-h {
  784. @apply absolute top-1/3 left-0 block h-1/3 w-full border-t border-b border-dashed border-gray-200/50;
  785. }
  786. .cropper-dashed-v {
  787. @apply absolute top-0 left-1/3 block h-full w-1/3 border-r border-l border-dashed border-gray-200/50;
  788. }
  789. /* 裁剪框拖拽区域 */
  790. .cropper-move-area {
  791. @apply absolute top-0 left-0 block h-full w-full cursor-move bg-white/10;
  792. }
  793. /* 边框拖拽线 */
  794. .cropper-line-e,
  795. .cropper-line-n,
  796. .cropper-line-w,
  797. .cropper-line-s {
  798. @apply absolute block bg-blue-500/10;
  799. }
  800. .cropper-line-e {
  801. @apply top-0 -right-0.75 h-full w-1;
  802. }
  803. .cropper-line-n {
  804. @apply -top-0.75 left-0 h-1 w-full;
  805. }
  806. .cropper-line-w {
  807. @apply top-0 -left-0.75 h-full w-1;
  808. }
  809. .cropper-line-s {
  810. @apply -bottom-0.75 left-0 h-1 w-full;
  811. }
  812. /* 拖拽点 */
  813. .cropper-point {
  814. @apply absolute flex h-2 w-2 items-center justify-center bg-blue-500;
  815. }
  816. .cropper-point-inner {
  817. @apply block h-1.5 w-1.5 bg-white;
  818. }
  819. /* 边角拖拽点位置和光标 */
  820. .cropper-point-ne {
  821. @apply -top-1.25 -right-1.25 cursor-ne-resize;
  822. }
  823. .cropper-point-nw {
  824. @apply -top-1.25 -left-1.25 cursor-nw-resize;
  825. }
  826. .cropper-point-sw {
  827. @apply -bottom-1.25 -left-1.25 cursor-sw-resize;
  828. }
  829. .cropper-point-se {
  830. @apply -right-1.25 -bottom-1.25 cursor-se-resize;
  831. }
  832. /* 边中点拖拽点位置和光标 */
  833. .cropper-point-e {
  834. @apply top-1/2 -right-1.25 -mt-1 cursor-e-resize;
  835. }
  836. .cropper-point-n {
  837. @apply -top-1.25 left-1/2 -ml-1 cursor-n-resize;
  838. }
  839. .cropper-point-w {
  840. @apply top-1/2 -left-1.25 -mt-1 cursor-w-resize;
  841. }
  842. .cropper-point-s {
  843. @apply -bottom-1.25 left-1/2 -ml-1 cursor-s-resize;
  844. }
  845. </style>