cl-cropper.uvue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969
  1. <template>
  2. <view
  3. class="cl-cropper"
  4. :class="[
  5. {
  6. 'is-disabled': disabled
  7. },
  8. pt.className
  9. ]"
  10. >
  11. <view class="cl-cropper__container" :class="[pt.inner?.className]">
  12. <!-- 图片容器 -->
  13. <view
  14. class="cl-cropper__image-container"
  15. @touchstart="onImageTouchStart"
  16. @touchmove="onImageTouchMove"
  17. @touchend="onImageTouchEnd"
  18. >
  19. <image
  20. class="cl-cropper__image"
  21. :class="[pt.image?.className]"
  22. :src="src"
  23. :style="imageStyle"
  24. @load="onImageLoad"
  25. ></image>
  26. </view>
  27. <!-- 裁剪框 -->
  28. <view
  29. class="cl-cropper__crop-box"
  30. :class="[pt.cropBox?.className]"
  31. :style="cropBoxStyle"
  32. >
  33. <view class="cl-cropper__crop-mask cl-cropper__crop-mask--top"></view>
  34. <view class="cl-cropper__crop-mask cl-cropper__crop-mask--right"></view>
  35. <view class="cl-cropper__crop-mask cl-cropper__crop-mask--bottom"></view>
  36. <view class="cl-cropper__crop-mask cl-cropper__crop-mask--left"></view>
  37. <!-- 裁剪区域 -->
  38. <view
  39. class="cl-cropper__crop-area"
  40. :class="{ 'is-resizing': isResizing }"
  41. @touchstart="onCropAreaTouchStart"
  42. @touchmove="onCropAreaTouchMove"
  43. @touchend="onCropAreaTouchEnd"
  44. >
  45. <!-- 辅助线 -->
  46. <view class="cl-cropper__guide-lines" v-if="showGuideLines">
  47. <view class="cl-cropper__guide-line cl-cropper__guide-line--h1"></view>
  48. <view class="cl-cropper__guide-line cl-cropper__guide-line--h2"></view>
  49. <view class="cl-cropper__guide-line cl-cropper__guide-line--v1"></view>
  50. <view class="cl-cropper__guide-line cl-cropper__guide-line--v2"></view>
  51. </view>
  52. <!-- 拖拽点 -->
  53. <view
  54. class="cl-cropper__drag-point cl-cropper__drag-point--tl"
  55. @touchstart="onResizeTouchStart"
  56. @touchmove="onResizeTouchMove"
  57. @touchend="onResizeTouchEnd"
  58. data-direction="tl"
  59. ></view>
  60. <view
  61. class="cl-cropper__drag-point cl-cropper__drag-point--tr"
  62. @touchstart="onResizeTouchStart"
  63. @touchmove="onResizeTouchMove"
  64. @touchend="onResizeTouchEnd"
  65. data-direction="tr"
  66. ></view>
  67. <view
  68. class="cl-cropper__drag-point cl-cropper__drag-point--bl"
  69. @touchstart="onResizeTouchStart"
  70. @touchmove="onResizeTouchMove"
  71. @touchend="onResizeTouchEnd"
  72. data-direction="bl"
  73. ></view>
  74. <view
  75. class="cl-cropper__drag-point cl-cropper__drag-point--br"
  76. @touchstart="onResizeTouchStart"
  77. @touchmove="onResizeTouchMove"
  78. @touchend="onResizeTouchEnd"
  79. data-direction="br"
  80. ></view>
  81. </view>
  82. </view>
  83. <!-- 操作按钮 -->
  84. <view class="cl-cropper__buttons" v-if="showButtons">
  85. <cl-button
  86. type="light"
  87. size="small"
  88. :pt="{ className: pt.button?.className }"
  89. @tap="reset"
  90. >
  91. 重置
  92. </cl-button>
  93. <cl-button
  94. type="primary"
  95. size="small"
  96. :pt="{ className: pt.button?.className }"
  97. @tap="crop"
  98. >
  99. 裁剪
  100. </cl-button>
  101. </view>
  102. </view>
  103. </view>
  104. </template>
  105. <script setup lang="ts">
  106. import { computed, ref, reactive, onMounted, type PropType } from "vue";
  107. import type { PassThroughProps } from "../../types";
  108. import { parsePt, parseRpx } from "@/cool";
  109. // 类型定义
  110. type RectType = {
  111. height: number;
  112. width: number;
  113. };
  114. type ImageInfoType = {
  115. width: number;
  116. height: number;
  117. loaded: boolean;
  118. };
  119. type ImageTransformType = {
  120. translateX: number;
  121. translateY: number;
  122. };
  123. type ImageDisplayType = {
  124. width: number;
  125. height: number;
  126. };
  127. type CropBoxType = {
  128. x: number;
  129. y: number;
  130. width: number;
  131. height: number;
  132. };
  133. type TouchStateType = {
  134. startX: number;
  135. startY: number;
  136. startDistance: number;
  137. startWidth: number;
  138. startHeight: number;
  139. startTranslateX: number;
  140. startTranslateY: number;
  141. touching: boolean;
  142. mode: string; // image, crop, resize
  143. resizeDirection: string; // tl, tr, bl, br
  144. };
  145. defineOptions({
  146. name: "cl-cropper"
  147. });
  148. const props = defineProps({
  149. // 透传样式
  150. pt: {
  151. type: Object,
  152. default: () => ({})
  153. },
  154. // 图片源
  155. src: {
  156. type: String,
  157. default: ""
  158. },
  159. // 裁剪框宽度
  160. cropWidth: {
  161. type: Number,
  162. default: 300
  163. },
  164. // 裁剪框高度
  165. cropHeight: {
  166. type: Number,
  167. default: 300
  168. },
  169. // 最大缩放比例
  170. maxScale: {
  171. type: Number,
  172. default: 3
  173. },
  174. // 最小缩放比例
  175. minScale: {
  176. type: Number,
  177. default: 0.5
  178. },
  179. // 是否显示操作按钮
  180. showButtons: {
  181. type: Boolean,
  182. default: true
  183. },
  184. // 输出图片质量
  185. quality: {
  186. type: Number,
  187. default: 0.9
  188. },
  189. // 输出图片格式
  190. format: {
  191. type: String as PropType<"jpg" | "png">,
  192. default: "jpg"
  193. },
  194. // 是否禁用
  195. disabled: {
  196. type: Boolean,
  197. default: false
  198. }
  199. });
  200. // 事件定义
  201. const emit = defineEmits(["crop", "load", "error"]);
  202. // 透传样式类型
  203. type PassThrough = {
  204. className?: string;
  205. inner?: PassThroughProps;
  206. image?: PassThroughProps;
  207. cropBox?: PassThroughProps;
  208. button?: PassThroughProps;
  209. };
  210. // 解析透传样式
  211. const pt = computed(() => parsePt<PassThrough>(props.pt));
  212. const { windowHeight, windowWidth } = uni.getWindowInfo();
  213. const rect = reactive<RectType>({
  214. height: windowHeight,
  215. width: windowWidth
  216. });
  217. // 图片信息
  218. const imageInfo = reactive<ImageInfoType>({
  219. width: 0,
  220. height: 0,
  221. loaded: false
  222. });
  223. // 图片变换状态
  224. const imageTransform = reactive<ImageTransformType>({
  225. translateX: 0,
  226. translateY: 0
  227. });
  228. // 图片显示尺寸
  229. const imageDisplay = reactive<ImageDisplayType>({
  230. width: 0,
  231. height: 0
  232. });
  233. // 裁剪框状态
  234. const cropBox = reactive<CropBoxType>({
  235. x: 0,
  236. y: 0,
  237. width: props.cropWidth,
  238. height: props.cropHeight
  239. });
  240. // 触摸状态
  241. const touchState = reactive<TouchStateType>({
  242. startX: 0,
  243. startY: 0,
  244. startDistance: 0,
  245. startWidth: 0,
  246. startHeight: 0,
  247. startTranslateX: 0,
  248. startTranslateY: 0,
  249. touching: false,
  250. mode: "", // image, crop, resize
  251. resizeDirection: "" // tl, tr, bl, br
  252. });
  253. // 缩放状态
  254. const isResizing = ref(false);
  255. const showGuideLines = ref(false);
  256. // 计算图片样式
  257. const imageStyle = computed(() => {
  258. if (touchState.touching) {
  259. return {
  260. transform: `translate(${imageTransform.translateX}px, ${imageTransform.translateY}px)`,
  261. transitionProperty: "none",
  262. height: imageDisplay.height + "px",
  263. width: imageDisplay.width + "px"
  264. };
  265. } else {
  266. return {
  267. transform: `translate(${imageTransform.translateX}px, ${imageTransform.translateY}px)`,
  268. transitionProperty: "transform, width, height",
  269. transitionDuration: "0.3s",
  270. height: imageDisplay.height + "px",
  271. width: imageDisplay.width + "px"
  272. };
  273. }
  274. });
  275. // 计算裁剪框样式
  276. const cropBoxStyle = computed(() => {
  277. return {
  278. left: `${cropBox.x}px`,
  279. top: `${cropBox.y}px`,
  280. width: `${cropBox.width}px`,
  281. height: `${cropBox.height}px`
  282. };
  283. });
  284. // 图片加载完成
  285. function onImageLoad(e: any) {
  286. imageInfo.width = e.detail.width;
  287. imageInfo.height = e.detail.height;
  288. imageInfo.loaded = true;
  289. // 初始化裁剪框位置
  290. initCropBox();
  291. // 设置初始图片尺寸,确保图片按比例适配到裁剪框
  292. setInitialSize();
  293. // 检查边界,确保图片覆盖裁剪框
  294. adjustImageBounds();
  295. emit("load", e);
  296. }
  297. // 设置初始图片尺寸,确保图片按比例适配到裁剪框
  298. function setInitialSize() {
  299. if (!imageInfo.loaded || !imageInfo.width || !imageInfo.height) {
  300. return;
  301. }
  302. console.log(imageInfo.height, imageInfo.width);
  303. // 计算图片在容器中的基础显示尺寸
  304. const containerWidth = rect.width;
  305. const containerHeight = rect.height;
  306. const imageAspectRatio = imageInfo.width / imageInfo.height;
  307. const containerAspectRatio = containerWidth / containerHeight;
  308. let baseDisplayWidth: number;
  309. let baseDisplayHeight: number;
  310. if (imageAspectRatio > containerAspectRatio) {
  311. // 图片较宽,以容器宽度为准
  312. baseDisplayWidth = containerWidth;
  313. baseDisplayHeight = containerWidth / imageAspectRatio;
  314. } else {
  315. // 图片较高,以容器高度为准
  316. baseDisplayHeight = containerHeight;
  317. baseDisplayWidth = containerHeight * imageAspectRatio;
  318. }
  319. // 计算确保能覆盖裁剪框的最小尺寸
  320. const minScaleForWidth = cropBox.width / baseDisplayWidth;
  321. const minScaleForHeight = cropBox.height / baseDisplayHeight;
  322. const minScale = Math.max(minScaleForWidth, minScaleForHeight);
  323. // 设置图片显示尺寸
  324. imageDisplay.width = baseDisplayWidth * minScale;
  325. imageDisplay.height = baseDisplayHeight * minScale;
  326. }
  327. // 计算图片最小显示尺寸(确保图片尺寸不小于裁剪框)
  328. function calculateMinSize() {
  329. if (!imageInfo.loaded || !imageInfo.width || !imageInfo.height) {
  330. return { width: 0, height: 0 };
  331. }
  332. const containerWidth = rect.width;
  333. const containerHeight = rect.height;
  334. // 计算图片在容器中的基础显示尺寸(mode="aspectFit"的显示尺寸)
  335. const imageAspectRatio = imageInfo.width / imageInfo.height;
  336. const containerAspectRatio = containerWidth / containerHeight;
  337. let baseDisplayWidth: number;
  338. let baseDisplayHeight: number;
  339. if (imageAspectRatio > containerAspectRatio) {
  340. // 图片较宽,以容器宽度为准
  341. baseDisplayWidth = containerWidth;
  342. baseDisplayHeight = containerWidth / imageAspectRatio;
  343. } else {
  344. // 图片较高,以容器高度为准
  345. baseDisplayHeight = containerHeight;
  346. baseDisplayWidth = containerHeight * imageAspectRatio;
  347. }
  348. // 计算使图片尺寸能够完全覆盖裁剪框的最小尺寸
  349. const minScaleForWidth = cropBox.width / baseDisplayWidth;
  350. const minScaleForHeight = cropBox.height / baseDisplayHeight;
  351. const minScale = Math.max(minScaleForWidth, minScaleForHeight);
  352. // 应用用户设置的最小缩放限制
  353. const finalScale = Math.max(props.minScale, minScale * 1.01); // 增加1%的缓冲
  354. return {
  355. width: baseDisplayWidth * finalScale,
  356. height: baseDisplayHeight * finalScale
  357. };
  358. }
  359. // 初始化裁剪框
  360. function initCropBox() {
  361. const containerWidth = rect.width;
  362. const containerHeight = rect.height;
  363. cropBox.width = props.cropWidth;
  364. cropBox.height = props.cropHeight;
  365. cropBox.x = (containerWidth - cropBox.width) / 2;
  366. cropBox.y = (containerHeight - cropBox.height) / 2;
  367. // 图片加载完成后,确保当前图片尺寸不小于最小要求
  368. if (imageInfo.loaded) {
  369. const minSize = calculateMinSize();
  370. if (imageDisplay.width < minSize.width || imageDisplay.height < minSize.height) {
  371. imageDisplay.width = minSize.width;
  372. imageDisplay.height = minSize.height;
  373. }
  374. }
  375. }
  376. // 图片触摸开始
  377. function onImageTouchStart(e: TouchEvent) {
  378. if (props.disabled || !imageInfo.loaded) return;
  379. touchState.touching = true;
  380. touchState.mode = "image";
  381. if (e.touches != null && e.touches.length == 1) {
  382. // 单指拖拽
  383. touchState.startX = e.touches[0].clientX;
  384. touchState.startY = e.touches[0].clientY;
  385. touchState.startTranslateX = imageTransform.translateX;
  386. touchState.startTranslateY = imageTransform.translateY;
  387. } else if (e.touches != null && e.touches.length == 2) {
  388. // 双指缩放
  389. const touch1 = e.touches[0];
  390. const touch2 = e.touches[1];
  391. // 计算两指间距离
  392. touchState.startDistance = Math.sqrt(
  393. Math.pow(touch2.clientX - touch1.clientX, 2) +
  394. Math.pow(touch2.clientY - touch1.clientY, 2)
  395. );
  396. touchState.startWidth = imageDisplay.width;
  397. touchState.startHeight = imageDisplay.height;
  398. // 记录缩放中心点(两指中心)
  399. touchState.startX = (touch1.clientX + touch2.clientX) / 2;
  400. touchState.startY = (touch1.clientY + touch2.clientY) / 2;
  401. touchState.startTranslateX = imageTransform.translateX;
  402. touchState.startTranslateY = imageTransform.translateY;
  403. }
  404. }
  405. // 图片触摸移动
  406. function onImageTouchMove(e: TouchEvent) {
  407. if (props.disabled || !touchState.touching || touchState.mode != "image") return;
  408. e.preventDefault();
  409. if (e.touches != null && e.touches.length == 1) {
  410. // 单指拖拽
  411. const deltaX = e.touches[0].clientX - touchState.startX;
  412. const deltaY = e.touches[0].clientY - touchState.startY;
  413. // 计算新位置
  414. const newTranslateX = touchState.startTranslateX + deltaX;
  415. const newTranslateY = touchState.startTranslateY + deltaY;
  416. // 应用新位置(在移动过程中不做边界检查,等移动结束后再检查)
  417. imageTransform.translateX = newTranslateX;
  418. imageTransform.translateY = newTranslateY;
  419. } else if (e.touches != null && e.touches.length == 2) {
  420. // 双指缩放
  421. const touch1 = e.touches[0];
  422. const touch2 = e.touches[1];
  423. // 计算当前两指间距离
  424. const distance = Math.sqrt(
  425. Math.pow(touch2.clientX - touch1.clientX, 2) +
  426. Math.pow(touch2.clientY - touch1.clientY, 2)
  427. );
  428. // 计算尺寸缩放倍数
  429. const scaleFactor = distance / touchState.startDistance;
  430. // 计算新的图片尺寸
  431. const newWidth = touchState.startWidth * scaleFactor;
  432. const newHeight = touchState.startHeight * scaleFactor;
  433. // 检查尺寸限制
  434. const minSize = calculateMinSize();
  435. const containerWidth = rect.width;
  436. const containerHeight = rect.height;
  437. const maxWidth = containerWidth * props.maxScale;
  438. const maxHeight = containerHeight * props.maxScale;
  439. // 应用尺寸限制
  440. const finalWidth = Math.max(minSize.width, Math.min(maxWidth, newWidth));
  441. const finalHeight = Math.max(minSize.height, Math.min(maxHeight, newHeight));
  442. // 计算当前缩放中心点
  443. const centerX = (touch1.clientX + touch2.clientX) / 2;
  444. const centerY = (touch1.clientY + touch2.clientY) / 2;
  445. // 计算缩放中心相对于容器的偏移
  446. const containerCenterX = containerWidth / 2;
  447. const containerCenterY = containerHeight / 2;
  448. // 计算尺寸变化引起的位移调整
  449. const widthDelta = finalWidth - touchState.startWidth;
  450. const heightDelta = finalHeight - touchState.startHeight;
  451. // 根据缩放中心调整位移
  452. const offsetX = ((centerX - containerCenterX) * widthDelta) / (2 * touchState.startWidth);
  453. const offsetY = ((centerY - containerCenterY) * heightDelta) / (2 * touchState.startHeight);
  454. imageDisplay.width = finalWidth;
  455. imageDisplay.height = finalHeight;
  456. imageTransform.translateX = touchState.startTranslateX - offsetX;
  457. imageTransform.translateY = touchState.startTranslateY - offsetY;
  458. }
  459. }
  460. // 图片触摸结束
  461. function onImageTouchEnd(e: TouchEvent) {
  462. touchState.touching = false;
  463. touchState.mode = "";
  464. // 检查并调整图片边界,确保覆盖整个裁剪框
  465. adjustImageBounds();
  466. }
  467. // 裁剪区域触摸开始 - 用于在裁剪框内拖拽图片
  468. function onCropAreaTouchStart(e: TouchEvent) {
  469. // 如果触摸点在拖拽点上,不处理图片拖拽
  470. const target = e.target as HTMLElement;
  471. if (target.classList.contains("cl-cropper__drag-point")) {
  472. return;
  473. }
  474. // 调用图片拖拽逻辑
  475. onImageTouchStart(e);
  476. }
  477. // 裁剪区域触摸移动 - 用于在裁剪框内拖拽图片
  478. function onCropAreaTouchMove(e: TouchEvent) {
  479. // 如果不是图片拖拽模式,不处理
  480. if (touchState.mode != "image") {
  481. return;
  482. }
  483. // 调用图片拖拽逻辑
  484. onImageTouchMove(e);
  485. }
  486. // 裁剪区域触摸结束 - 用于在裁剪框内拖拽图片
  487. function onCropAreaTouchEnd(e: TouchEvent) {
  488. // 如果不是图片拖拽模式,不处理
  489. if (touchState.mode != "image") {
  490. return;
  491. }
  492. // 调用图片拖拽逻辑
  493. onImageTouchEnd(e);
  494. }
  495. // 裁剪框缩放开始
  496. function onResizeTouchStart(e: TouchEvent) {
  497. if (props.disabled) return;
  498. e.stopPropagation();
  499. touchState.touching = true;
  500. touchState.mode = "resize";
  501. isResizing.value = true;
  502. showGuideLines.value = true;
  503. // 从 data-direction 属性获取缩放方向
  504. const target = e.target as HTMLElement;
  505. touchState.resizeDirection = target.getAttribute("data-direction") || "";
  506. if (e.touches != null && e.touches.length == 1) {
  507. touchState.startX = e.touches[0].clientX;
  508. touchState.startY = e.touches[0].clientY;
  509. }
  510. }
  511. // 裁剪框缩放移动
  512. function onResizeTouchMove(e: TouchEvent) {
  513. if (props.disabled || !touchState.touching || touchState.mode != "resize") return;
  514. e.preventDefault();
  515. e.stopPropagation();
  516. if (e.touches != null && e.touches.length == 1) {
  517. const deltaX = e.touches[0].clientX - touchState.startX;
  518. const deltaY = e.touches[0].clientY - touchState.startY;
  519. const containerWidth = rect.width;
  520. const containerHeight = rect.height;
  521. // 最小裁剪框尺寸
  522. const minSize = 50;
  523. let newX = cropBox.x;
  524. let newY = cropBox.y;
  525. let newWidth = cropBox.width;
  526. let newHeight = cropBox.height;
  527. // 根据拖拽方向调整裁剪框
  528. switch (touchState.resizeDirection) {
  529. case "tl": // 左上角
  530. newX = Math.max(0, cropBox.x + deltaX);
  531. newY = Math.max(0, cropBox.y + deltaY);
  532. newWidth = cropBox.width - deltaX;
  533. newHeight = cropBox.height - deltaY;
  534. break;
  535. case "tr": // 右上角
  536. newY = Math.max(0, cropBox.y + deltaY);
  537. newWidth = cropBox.width + deltaX;
  538. newHeight = cropBox.height - deltaY;
  539. break;
  540. case "bl": // 左下角
  541. newX = Math.max(0, cropBox.x + deltaX);
  542. newWidth = cropBox.width - deltaX;
  543. newHeight = cropBox.height + deltaY;
  544. break;
  545. case "br": // 右下角
  546. newWidth = cropBox.width + deltaX;
  547. newHeight = cropBox.height + deltaY;
  548. break;
  549. }
  550. // 确保尺寸不小于最小值且不超出容器
  551. if (newWidth >= minSize && newHeight >= minSize) {
  552. // 检查是否超出容器边界
  553. if (
  554. newX >= 0 &&
  555. newY >= 0 &&
  556. newX + newWidth <= containerWidth &&
  557. newY + newHeight <= containerHeight
  558. ) {
  559. cropBox.x = newX;
  560. cropBox.y = newY;
  561. cropBox.width = newWidth;
  562. cropBox.height = newHeight;
  563. // 更新起始位置
  564. touchState.startX = e.touches[0].clientX;
  565. touchState.startY = e.touches[0].clientY;
  566. }
  567. }
  568. }
  569. }
  570. // 裁剪框缩放结束
  571. function onResizeTouchEnd(e: TouchEvent) {
  572. touchState.touching = false;
  573. touchState.mode = "";
  574. touchState.resizeDirection = "";
  575. isResizing.value = false;
  576. // 拖拽结束后,自动调整裁剪框尺寸和图片缩放
  577. adjustCropBoxToDefault();
  578. // 延迟隐藏辅助线,给用户一点时间看到最终位置
  579. setTimeout(() => {
  580. showGuideLines.value = false;
  581. }, 500);
  582. }
  583. // 重置
  584. function reset() {
  585. // 重置裁剪框
  586. initCropBox();
  587. // 设置初始图片尺寸,确保图片按比例适配到裁剪框
  588. if (imageInfo.loaded) {
  589. setInitialSize();
  590. // 检查边界
  591. adjustImageBounds();
  592. } else {
  593. imageDisplay.width = 0;
  594. imageDisplay.height = 0;
  595. imageTransform.translateX = 0;
  596. imageTransform.translateY = 0;
  597. }
  598. }
  599. // 执行裁剪
  600. function crop() {
  601. if (!imageInfo.loaded) {
  602. emit("error", "图片未加载完成");
  603. return;
  604. }
  605. }
  606. // 调整裁剪框到默认尺寸(宽度恢复默认,高度按比例计算)
  607. function adjustCropBoxToDefault() {
  608. // 记录调整前的状态
  609. const oldWidth = cropBox.width;
  610. const oldHeight = cropBox.height;
  611. const oldImageWidth = imageDisplay.width;
  612. const oldImageHeight = imageDisplay.height;
  613. // 计算当前裁剪框的宽高比
  614. const currentRatio = cropBox.width / cropBox.height;
  615. // 设置宽度为默认值
  616. const newWidth = props.cropWidth;
  617. // 按当前比例计算新高度
  618. const newHeight = newWidth / currentRatio;
  619. const containerWidth = rect.width;
  620. const containerHeight = rect.height;
  621. // 确保新尺寸不超出容器
  622. if (newHeight <= containerHeight) {
  623. cropBox.width = newWidth;
  624. cropBox.height = newHeight;
  625. // 重新居中
  626. cropBox.x = (containerWidth - newWidth) / 2;
  627. cropBox.y = (containerHeight - newHeight) / 2;
  628. } else {
  629. // 如果高度超出,则以高度为准计算
  630. cropBox.height = Math.min(newHeight, containerHeight - 40); // 留一20px边距
  631. cropBox.width = cropBox.height * currentRatio;
  632. cropBox.x = (containerWidth - cropBox.width) / 2;
  633. cropBox.y = (containerHeight - cropBox.height) / 2;
  634. }
  635. // 裁剪框调整完成后,再次调整图片尺寸
  636. adjustImageSizeAfterCropResize(oldWidth, oldHeight, oldImageWidth, oldImageHeight);
  637. }
  638. // 裁剪框调整后的图片尺寸调整
  639. function adjustImageSizeAfterCropResize(
  640. oldWidth: number,
  641. oldHeight: number,
  642. oldImageWidth: number,
  643. oldImageHeight: number
  644. ) {
  645. if (!imageInfo.loaded) return;
  646. // 计算裁剪框尺寸变化比例
  647. const widthRatio = cropBox.width / oldWidth;
  648. const heightRatio = cropBox.height / oldHeight;
  649. const avgRatio = (widthRatio + heightRatio) / 2;
  650. // 根据裁剪框尺寸变化调整图片尺寸
  651. const adjustedWidth = oldImageWidth * avgRatio;
  652. const adjustedHeight = oldImageHeight * avgRatio;
  653. // 计算最终尺寸(强制确保能覆盖新的裁剪框)
  654. const minSize = calculateMinSize();
  655. const containerWidth = rect.width;
  656. const containerHeight = rect.height;
  657. const maxWidth = containerWidth * props.maxScale;
  658. const maxHeight = containerHeight * props.maxScale;
  659. // 强制应用新的尺寸,不允许小于最小值或大于最大值
  660. imageDisplay.width = Math.max(minSize.width, Math.min(maxWidth, adjustedWidth));
  661. imageDisplay.height = Math.max(minSize.height, Math.min(maxHeight, adjustedHeight));
  662. // 图片重新居中
  663. imageTransform.translateX = 0;
  664. imageTransform.translateY = 0;
  665. // 检查并调整图片边界
  666. adjustImageBounds();
  667. }
  668. // 检查并调整图片边界,确保图片完全覆盖裁剪框
  669. function adjustImageBounds() {
  670. if (!imageInfo.loaded) return;
  671. const containerWidth = rect.width;
  672. const containerHeight = rect.height;
  673. // 计算图片中心点在容器中的位置
  674. const imageCenterX = containerWidth / 2 + imageTransform.translateX;
  675. const imageCenterY = containerHeight / 2 + imageTransform.translateY;
  676. // 计算图片的边界
  677. const imageLeft = imageCenterX - imageDisplay.width / 2;
  678. const imageRight = imageCenterX + imageDisplay.width / 2;
  679. const imageTop = imageCenterY - imageDisplay.height / 2;
  680. const imageBottom = imageCenterY + imageDisplay.height / 2;
  681. // 裁剪框的边界
  682. const cropLeft = cropBox.x;
  683. const cropRight = cropBox.x + cropBox.width;
  684. const cropTop = cropBox.y;
  685. const cropBottom = cropBox.y + cropBox.height;
  686. // 检查图片是否完全覆盖裁剪框,如果不覆盖则调整位置
  687. let newTranslateX = imageTransform.translateX;
  688. let newTranslateY = imageTransform.translateY;
  689. // 检查水平方向
  690. if (imageLeft > cropLeft) {
  691. // 图片左边界在裁剪框左边界右侧,需要向左移动
  692. newTranslateX = imageTransform.translateX - (imageLeft - cropLeft);
  693. } else if (imageRight < cropRight) {
  694. // 图片右边界在裁剪框右边界左侧,需要向右移动
  695. newTranslateX = imageTransform.translateX + (cropRight - imageRight);
  696. }
  697. // 检查垂直方向
  698. if (imageTop > cropTop) {
  699. // 图片上边界在裁剪框上边界下方,需要向上移动
  700. newTranslateY = imageTransform.translateY - (imageTop - cropTop);
  701. } else if (imageBottom < cropBottom) {
  702. // 图片下边界在裁剪框下边界上方,需要向下移动
  703. newTranslateY = imageTransform.translateY + (cropBottom - imageBottom);
  704. }
  705. // 应用调整后的位置
  706. imageTransform.translateX = newTranslateX;
  707. imageTransform.translateY = newTranslateY;
  708. }
  709. // 初始化
  710. onMounted(() => {
  711. initCropBox();
  712. });
  713. </script>
  714. <style lang="scss" scoped>
  715. .cl-cropper {
  716. @apply bg-black absolute left-0 top-0 w-full h-full;
  717. z-index: 100;
  718. &.is-disabled {
  719. @apply opacity-50;
  720. }
  721. &__container {
  722. @apply relative w-full h-full;
  723. }
  724. &__image-container {
  725. @apply absolute top-0 left-0 flex items-center justify-center w-full h-full;
  726. z-index: 1;
  727. }
  728. &__image {
  729. @apply max-w-full max-h-full;
  730. }
  731. &__crop-box {
  732. @apply absolute;
  733. z-index: 2;
  734. pointer-events: none; // 让裁剪框本身不阻挡触摸事件
  735. }
  736. &__crop-area {
  737. @apply relative w-full h-full border border-white border-solid transition-all duration-300;
  738. pointer-events: auto; // 恢复裁剪区域的触摸事件
  739. &.is-resizing {
  740. @apply border-primary-500;
  741. }
  742. }
  743. &__crop-mask {
  744. @apply absolute bg-black opacity-50;
  745. &--top {
  746. @apply top-0 left-0 w-full;
  747. }
  748. &--right {
  749. @apply top-0 right-0 h-full;
  750. }
  751. &--bottom {
  752. @apply bottom-0 left-0 w-full;
  753. }
  754. &--left {
  755. @apply top-0 left-0 h-full;
  756. }
  757. }
  758. &__guide-lines {
  759. @apply absolute top-0 left-0 w-full h-full pointer-events-none;
  760. }
  761. &__guide-line {
  762. @apply absolute bg-white opacity-70;
  763. &--h1 {
  764. @apply top-1/3 left-0 w-full;
  765. height: 0.5px;
  766. }
  767. &--h2 {
  768. @apply top-2/3 left-0 w-full;
  769. height: 0.5px;
  770. }
  771. &--v1 {
  772. @apply left-1/3 top-0 h-full;
  773. width: 0.5px;
  774. }
  775. &--v2 {
  776. @apply left-2/3 top-0 h-full;
  777. width: 0.5px;
  778. }
  779. }
  780. &__drag-point {
  781. @apply absolute transition-all duration-200;
  782. touch-action: none;
  783. // 触发区域为40px
  784. width: 40px;
  785. height: 40px;
  786. // 使用粗线样式代替伪元素
  787. border: 3px solid white;
  788. background: transparent;
  789. &--tl {
  790. top: -20px;
  791. left: -20px;
  792. border-right: none;
  793. border-bottom: none;
  794. }
  795. &--tr {
  796. top: -20px;
  797. right: -20px;
  798. border-left: none;
  799. border-bottom: none;
  800. }
  801. &--bl {
  802. bottom: -20px;
  803. left: -20px;
  804. border-right: none;
  805. border-top: none;
  806. }
  807. &--br {
  808. bottom: -20px;
  809. right: -20px;
  810. border-left: none;
  811. border-top: none;
  812. }
  813. // 缩放时高亮显示
  814. .is-resizing & {
  815. @apply scale-110;
  816. border-color: #3b82f6;
  817. }
  818. }
  819. &__buttons {
  820. @apply absolute bottom-4 left-0 right-0 flex flex-row justify-center;
  821. }
  822. }
  823. </style>