cl-cropper.uvue 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351
  1. <template>
  2. <view
  3. class="cl-cropper"
  4. :class="[pt.className]"
  5. @touchstart="onTouchStart"
  6. @touchmove.stop.prevent="onTouchMove"
  7. @touchend="onTouchEnd"
  8. @touchcancel="onTouchEnd"
  9. v-if="visible"
  10. >
  11. <!-- 图片容器 - 可拖拽和缩放的图片区域 -->
  12. <view class="cl-cropper__image">
  13. <image
  14. class="cl-cropper__image-inner"
  15. :class="[
  16. {
  17. 'no-dragging': !touch.isTouching
  18. },
  19. pt.image?.className
  20. ]"
  21. :src="imageUrl"
  22. :style="imageStyle"
  23. @load="onImageLoaded"
  24. ></image>
  25. </view>
  26. <!-- 遮罩层 - 覆盖裁剪框外的区域 -->
  27. <view class="cl-cropper__mask" :class="[pt.mask?.className]">
  28. <view
  29. v-for="(item, index) in ['top', 'right', 'bottom', 'left']"
  30. :key="index"
  31. :class="`cl-cropper__mask-item cl-cropper__mask-item--${item}`"
  32. :style="maskStyle[item]!"
  33. ></view>
  34. </view>
  35. <!-- 裁剪框 - 可拖拽和调整大小的选择区域 -->
  36. <view class="cl-cropper__crop-box" :class="[pt.cropBox?.className]" :style="cropBoxStyle">
  37. <!-- 裁剪区域 - 内部可继续拖拽图片 -->
  38. <view class="cl-cropper__crop-area" :class="{ 'is-resizing': isResizing }">
  39. <!-- 九宫格辅助线 - 在调整大小时显示 -->
  40. <view
  41. class="cl-cropper__guide-lines"
  42. :class="{
  43. 'is-show': showGuideLines
  44. }"
  45. >
  46. <view class="cl-cropper__guide-line cl-cropper__guide-line--h1"></view>
  47. <view class="cl-cropper__guide-line cl-cropper__guide-line--h2"></view>
  48. <view class="cl-cropper__guide-line cl-cropper__guide-line--v1"></view>
  49. <view class="cl-cropper__guide-line cl-cropper__guide-line--v2"></view>
  50. <view class="cl-cropper__guide-text">
  51. <cl-text
  52. :pt="{
  53. className: '!text-xl'
  54. }"
  55. color="white"
  56. >
  57. {{ cropBox.width }}
  58. </cl-text>
  59. <cl-icon name="close-line" color="white"></cl-icon>
  60. <cl-text
  61. :pt="{
  62. className: '!text-xl'
  63. }"
  64. color="white"
  65. >
  66. {{ cropBox.height }}
  67. </cl-text>
  68. </view>
  69. </view>
  70. </view>
  71. <template v-if="resizable">
  72. <view
  73. v-for="item in ['tl', 'tr', 'bl', 'br']"
  74. :key="item"
  75. class="cl-cropper__drag-point"
  76. :class="[`cl-cropper__drag-point--${item}`]"
  77. @touchstart.stop="onResizeStart($event as TouchEvent, item)"
  78. >
  79. <view class="cl-cropper__corner-indicator"></view>
  80. </view>
  81. </template>
  82. </view>
  83. <!-- 底部按钮组 -->
  84. <view class="cl-cropper__op" :class="[pt.op?.className]" :style="opStyle" @touchmove.stop>
  85. <!-- 关闭 -->
  86. <view class="cl-cropper__op-item" :class="[pt.opItem?.className]">
  87. <cl-icon name="close-line" color="white" :size="50" @tap="close"></cl-icon>
  88. </view>
  89. <!-- 旋转 -->
  90. <view class="cl-cropper__op-item" :class="[pt.opItem?.className]">
  91. <cl-icon
  92. name="anticlockwise-line"
  93. color="white"
  94. :size="40"
  95. @tap="rotate90"
  96. ></cl-icon>
  97. </view>
  98. <!-- 重置 -->
  99. <view class="cl-cropper__op-item" :class="[pt.opItem?.className]">
  100. <cl-icon name="reset-right-line" color="white" :size="40" @tap="reset"></cl-icon>
  101. </view>
  102. <!-- 重新选择 -->
  103. <view class="cl-cropper__op-item" :class="[pt.opItem?.className]">
  104. <cl-icon name="image-line" color="white" :size="40" @tap="chooseImage"></cl-icon>
  105. </view>
  106. <!-- 确定 -->
  107. <view class="cl-cropper__op-item" :class="[pt.opItem?.className]">
  108. <cl-icon name="check-line" color="white" :size="50" @tap="toPng"></cl-icon>
  109. </view>
  110. </view>
  111. <!-- 裁剪用 -->
  112. <view class="cl-cropper__canvas">
  113. <canvas
  114. ref="canvasRef"
  115. :id="canvasId"
  116. :style="{
  117. height: `${cropBox.height}px`,
  118. width: `${cropBox.width}px`
  119. }"
  120. ></canvas>
  121. </view>
  122. </view>
  123. </template>
  124. <script setup lang="ts">
  125. import { computed, ref, reactive, nextTick, getCurrentInstance } from "vue";
  126. import type { PassThroughProps } from "../../types";
  127. import { canvasToPng, getDevicePixelRatio, getSafeAreaHeight, parsePt, uuid } from "@/cool";
  128. // 定义遮罩层样式类型
  129. type MaskStyle = {
  130. top: UTSJSONObject; // 上方遮罩样式
  131. right: UTSJSONObject; // 右侧遮罩样式
  132. bottom: UTSJSONObject; // 下方遮罩样式
  133. left: UTSJSONObject; // 左侧遮罩样式
  134. };
  135. // 定义图片信息类型
  136. type ImageInfo = {
  137. width: number; // 图片原始宽度
  138. height: number; // 图片原始高度
  139. isLoaded: boolean; // 图片是否已加载
  140. };
  141. // 定义图片变换类型
  142. type Transform = {
  143. translateX: number; // 水平位移
  144. translateY: number; // 垂直位移
  145. };
  146. // 定义尺寸类型
  147. type Size = {
  148. width: number; // 宽度
  149. height: number; // 高度
  150. };
  151. // 定义裁剪框类型
  152. type CropBox = {
  153. x: number; // 裁剪框 x 坐标
  154. y: number; // 裁剪框 y 坐标
  155. width: number; // 裁剪框宽度
  156. height: number; // 裁剪框高度
  157. };
  158. // 定义触摸状态类型
  159. type TouchState = {
  160. startX: number; // 触摸开始 x 坐标
  161. startY: number; // 触摸开始 y 坐标
  162. startDistance: number; // 双指触摸开始距离
  163. startImageWidth: number; // 触摸开始时图片宽度
  164. startImageHeight: number; // 触摸开始时图片高度
  165. startTranslateX: number; // 触摸开始时水平位移
  166. startTranslateY: number; // 触摸开始时垂直位移
  167. startCropBoxWidth: number; // 触摸开始时裁剪框宽度
  168. startCropBoxHeight: number; // 触摸开始时裁剪框高度
  169. isTouching: boolean; // 是否正在触摸
  170. mode: string; // 触摸模式:image/resizing
  171. direction: string; // 调整方向:tl/tr/bl/br
  172. };
  173. // 定义组件选项
  174. defineOptions({
  175. name: "cl-cropper" // 组件名称
  176. });
  177. // 定义组件属性
  178. const props = defineProps({
  179. // 透传样式配置对象
  180. pt: {
  181. type: Object,
  182. default: () => ({})
  183. },
  184. // 裁剪框初始宽度(像素)
  185. cropWidth: {
  186. type: Number,
  187. default: 300
  188. },
  189. // 裁剪框初始高度(像素)
  190. cropHeight: {
  191. type: Number,
  192. default: 300
  193. },
  194. // 图片最大缩放倍数
  195. maxScale: {
  196. type: Number,
  197. default: 3
  198. },
  199. // 是否可以自定义裁剪框大小
  200. resizable: {
  201. type: Boolean,
  202. default: false
  203. }
  204. });
  205. // 定义事件发射器
  206. const emit = defineEmits(["crop", "load", "error"]);
  207. // 获取当前实例
  208. const { proxy } = getCurrentInstance()!;
  209. // 创建唯一的canvas ID
  210. const canvasId = `cl-cropper__${uuid()}`;
  211. // 创建canvas实例
  212. const canvasRef = ref<UniElement | null>(null);
  213. // 像素取整工具函数 - 避免小数点造成的样式兼容问题
  214. function toPixel(value: number): number {
  215. return Math.round(value); // 四舍五入取整
  216. }
  217. // 定义透传样式配置类型
  218. type PassThrough = {
  219. className?: string; // 组件根元素类名
  220. image?: PassThroughProps; // 图片元素透传属性
  221. op?: PassThroughProps; // 底部按钮组透传属性
  222. opItem?: PassThroughProps; // 按钮透传属性
  223. mask?: PassThroughProps; // 遮罩层透传属性
  224. cropBox?: PassThroughProps; // 裁剪框透传属性
  225. };
  226. // 解析透传样式配置
  227. const pt = computed(() => parsePt<PassThrough>(props.pt));
  228. // 创建容器尺寸响应式对象
  229. const container = reactive<Size>({
  230. height: 0, // 获取视图高度
  231. width: 0 // 获取视图宽度
  232. });
  233. // 创建图片信息响应式对象
  234. const imageInfo = reactive<ImageInfo>({
  235. width: 0, // 初始宽度为 0
  236. height: 0, // 初始高度为 0
  237. isLoaded: false // 初始加载状态为未加载
  238. });
  239. // 创建图片变换响应式对象
  240. const transform = reactive<Transform>({
  241. translateX: 0, // 初始水平位移为 0
  242. translateY: 0 // 初始垂直位移为 0
  243. });
  244. // 创建图片尺寸响应式对象
  245. const imageSize = reactive<Size>({
  246. width: 0, // 初始显示宽度为 0
  247. height: 0 // 初始显示高度为 0
  248. });
  249. // 创建裁剪框响应式对象
  250. const cropBox = reactive<CropBox>({
  251. x: 0, // 初始 x 坐标为 0
  252. y: 0, // 初始 y 坐标为 0
  253. width: props.cropWidth, // 使用传入的裁剪框宽度
  254. height: props.cropHeight // 使用传入的裁剪框高度
  255. });
  256. // 创建触摸状态响应式对象
  257. const touch = reactive<TouchState>({
  258. startX: 0, // 初始触摸 x 坐标为 0
  259. startY: 0, // 初始触摸 y 坐标为 0
  260. startDistance: 0, // 初始双指距离为 0
  261. startImageWidth: 0, // 初始图片宽度为 0
  262. startImageHeight: 0, // 初始图片高度为 0
  263. startTranslateX: 0, // 初始水平位移为 0
  264. startTranslateY: 0, // 初始垂直位移为 0
  265. startCropBoxWidth: 0, // 初始裁剪框宽度为 0
  266. startCropBoxHeight: 0, // 初始裁剪框高度为 0
  267. isTouching: false, // 初始触摸状态为未触摸
  268. mode: "", // 初始触摸模式为空
  269. direction: "" // 初始调整方向为空
  270. });
  271. // 是否正在调整裁剪框大小
  272. const isResizing = ref(false);
  273. // 是否显示九宫格辅助线
  274. const showGuideLines = ref(false);
  275. // 图片翻转状态
  276. const flipHorizontal = ref(false); // 水平翻转状态
  277. const flipVertical = ref(false); // 垂直翻转状态
  278. // 图片旋转状态
  279. const rotate = ref(0); // 旋转状态
  280. // 计算图片样式
  281. const imageStyle = computed(() => {
  282. // 构建翻转变换
  283. const flipX = flipHorizontal.value ? "scaleX(-1)" : "scaleX(1)";
  284. const flipY = flipVertical.value ? "scaleY(-1)" : "scaleY(1)";
  285. let height = toPixel(imageSize.height);
  286. let width = toPixel(imageSize.width);
  287. // 解决 ios 端高和宽为0时不触发 load 事件
  288. if (height == 0) {
  289. height = 1;
  290. width = 1;
  291. }
  292. // 创建基础样式对象
  293. const style = {
  294. transform: `translate(${toPixel(transform.translateX)}px, ${toPixel(transform.translateY)}px) ${flipX} ${flipY} rotate(${rotate.value}deg)`, // 设置图片位移和翻转变换
  295. height: height + "px", // 设置图片显示高度
  296. width: width + "px", // 设置图片显示宽度
  297. opacity: height == 0 ? 0 : 1 // 设置图片显示透明度
  298. };
  299. // 返回样式对象
  300. return style;
  301. });
  302. // 计算裁剪框样式
  303. const cropBoxStyle = computed(() => {
  304. // 返回裁剪框定位和尺寸样式
  305. return {
  306. left: `${toPixel(cropBox.x)}px`, // 设置裁剪框左边距
  307. top: `${toPixel(cropBox.y)}px`, // 设置裁剪框上边距
  308. width: `${toPixel(cropBox.width)}px`, // 设置裁剪框宽度
  309. height: `${toPixel(cropBox.height)}px` // 设置裁剪框高度
  310. };
  311. });
  312. // 计算遮罩层样式
  313. const maskStyle = computed<MaskStyle>(() => {
  314. // 返回四个方向的遮罩样式
  315. return {
  316. // 上方遮罩样式
  317. top: {
  318. height: `${toPixel(cropBox.y)}px`, // 遮罩高度到裁剪框顶部
  319. width: `${toPixel(cropBox.width)}px`, // 遮罩宽度占满容器
  320. left: `${toPixel(cropBox.x)}px`
  321. },
  322. // 右侧遮罩样式
  323. right: {
  324. width: `${toPixel(container.width - cropBox.x - cropBox.width)}px`, // 遮罩宽度为容器宽度减去裁剪框右边距
  325. height: "100%", // 遮罩高度与裁剪框相同
  326. top: 0, // 遮罩顶部对齐裁剪框
  327. left: `${toPixel(cropBox.x + cropBox.width)}px` // 遮罩贴右边
  328. },
  329. // 下方遮罩样式
  330. bottom: {
  331. height: `${toPixel(container.height - cropBox.y - cropBox.height)}px`, // 遮罩高度为容器高度减去裁剪框下边距
  332. width: `${toPixel(cropBox.width)}px`, // 遮罩宽度占满容器
  333. bottom: 0, // 遮罩贴底部
  334. left: `${toPixel(cropBox.x)}px`
  335. },
  336. // 左侧遮罩样式
  337. left: {
  338. width: `${toPixel(cropBox.x)}px`, // 遮罩宽度到裁剪框左边
  339. height: "100%", // 遮罩高度与裁剪框相同
  340. left: 0
  341. }
  342. };
  343. });
  344. // 底部按钮组样式
  345. const opStyle = computed(() => {
  346. let bottom = getSafeAreaHeight("bottom");
  347. if (bottom == 0) {
  348. bottom = 10;
  349. }
  350. return {
  351. bottom: bottom + "px"
  352. };
  353. });
  354. // 计算旋转后图片的有效尺寸的函数
  355. function getRotatedImageSize(): Size {
  356. // 获取旋转角度(转换为0-360度范围内的正值)
  357. const angle = ((rotate.value % 360) + 360) % 360;
  358. // 如果是90度或270度旋转,宽高需要交换
  359. if (angle == 90 || angle == 270) {
  360. return {
  361. width: imageSize.height,
  362. height: imageSize.width
  363. };
  364. }
  365. // 0度或180度旋转,宽高保持不变
  366. return {
  367. width: imageSize.width,
  368. height: imageSize.height
  369. };
  370. }
  371. // 计算双指缩放时的最小图片尺寸
  372. function getMinImageSizeForPinch(): Size {
  373. // 如果图片未加载,返回零尺寸
  374. if (!imageInfo.isLoaded) {
  375. return { width: 0, height: 0 };
  376. }
  377. // 计算图片原始宽高比
  378. const originalRatio = imageInfo.width / imageInfo.height;
  379. // 获取旋转角度
  380. const angle = ((rotate.value % 360) + 360) % 360;
  381. // 获取裁剪框需要的最小覆盖尺寸
  382. let requiredW: number; // 旋转后需要覆盖裁剪框宽度的图片实际尺寸
  383. let requiredH: number; // 旋转后需要覆盖裁剪框高度的图片实际尺寸
  384. if (angle == 90 || angle == 270) {
  385. // 旋转90度/270度时,图片的宽变成高,高变成宽
  386. // 所以图片实际宽度需要覆盖裁剪框高度,实际高度需要覆盖裁剪框宽度
  387. requiredW = cropBox.height;
  388. requiredH = cropBox.width;
  389. } else {
  390. // 0度或180度时,正常对应
  391. requiredW = cropBox.width;
  392. requiredH = cropBox.height;
  393. }
  394. // 根据图片原始比例,计算能满足覆盖要求的最小尺寸
  395. let minW: number;
  396. let minH: number;
  397. // 比较哪个约束更严格
  398. if (requiredW / originalRatio > requiredH) {
  399. // 宽度约束更严格
  400. minW = requiredW;
  401. minH = requiredW / originalRatio;
  402. } else {
  403. // 高度约束更严格
  404. minH = requiredH;
  405. minW = requiredH * originalRatio;
  406. }
  407. return {
  408. width: toPixel(minW),
  409. height: toPixel(minH)
  410. };
  411. }
  412. // 计算图片最小尺寸的函数
  413. function getMinImageSize(): Size {
  414. // 如果图片未加载或尺寸无效,返回零尺寸
  415. if (!imageInfo.isLoaded || imageInfo.width == 0 || imageInfo.height == 0) {
  416. return { width: 0, height: 0 }; // 返回空尺寸对象
  417. }
  418. // 获取考虑旋转后的图片有效宽高
  419. const angle = ((rotate.value % 360) + 360) % 360;
  420. let effectiveWidth = imageInfo.width;
  421. let effectiveHeight = imageInfo.height;
  422. // 如果旋转90度或270度,宽高交换
  423. if (angle == 90 || angle == 270) {
  424. effectiveWidth = imageInfo.height;
  425. effectiveHeight = imageInfo.width;
  426. }
  427. // 计算图片宽高比(使用旋转后的有效尺寸)
  428. const ratio = effectiveWidth / effectiveHeight;
  429. // 计算容器宽高比
  430. const containerRatio = container.width / container.height;
  431. // 声明基础显示尺寸变量
  432. let baseW: number; // 基础显示宽度
  433. let baseH: number; // 基础显示高度
  434. // 根据图片和容器的宽高比决定缩放方式
  435. if (ratio > containerRatio) {
  436. baseW = container.width; // 宽度占满容器
  437. baseH = container.width / ratio; // 高度按比例缩放
  438. } else {
  439. baseH = container.height; // 高度占满容器
  440. baseW = container.height * ratio; // 宽度按比例缩放
  441. }
  442. // 计算覆盖裁剪框所需的最小缩放比例
  443. const scaleW = cropBox.width / baseW; // 宽度缩放比例
  444. const scaleH = cropBox.height / baseH; // 高度缩放比例
  445. const minScale = Math.max(scaleW, scaleH); // 取最大缩放比例确保完全覆盖
  446. // 增加少量容差确保完全覆盖
  447. const finalScale = minScale * 1.01;
  448. // 返回最终尺寸
  449. return {
  450. width: toPixel(baseW * finalScale), // 计算最终宽度
  451. height: toPixel(baseH * finalScale) // 计算最终高度
  452. };
  453. }
  454. // 初始化裁剪框的函数
  455. function initCrop() {
  456. const { windowHeight, windowWidth } = uni.getWindowInfo();
  457. // 设置容器尺寸为视口尺寸
  458. container.height = windowHeight;
  459. container.width = windowWidth;
  460. // 设置裁剪框尺寸为传入的初始值
  461. cropBox.width = props.cropWidth; // 设置裁剪框宽度
  462. cropBox.height = props.cropHeight; // 设置裁剪框高度
  463. // 计算裁剪框居中位置
  464. cropBox.x = toPixel((container.width - cropBox.width) / 2); // 水平居中
  465. cropBox.y = toPixel((container.height - cropBox.height) / 2); // 垂直居中
  466. // 如果图片已加载,确保图片尺寸满足最小要求
  467. if (imageInfo.isLoaded) {
  468. const minSize = getMinImageSize(); // 获取最小尺寸
  469. // 如果当前尺寸小于最小尺寸,更新为最小尺寸
  470. if (imageSize.width < minSize.width || imageSize.height < minSize.height) {
  471. imageSize.width = toPixel(minSize.width); // 更新图片显示宽度
  472. imageSize.height = toPixel(minSize.height); // 更新图片显示高度
  473. }
  474. }
  475. }
  476. // 设置初始图片尺寸的函数
  477. function setInitialImageSize() {
  478. // 如果图片未加载或尺寸无效,直接返回
  479. if (!imageInfo.isLoaded || imageInfo.width == 0 || imageInfo.height == 0) {
  480. return; // 提前退出函数
  481. }
  482. // 计算图片宽高比
  483. const ratio = imageInfo.width / imageInfo.height;
  484. // 计算容器宽高比
  485. const containerRatio = container.width / container.height;
  486. // 声明基础显示尺寸变量
  487. let baseW: number; // 基础显示宽度
  488. let baseH: number; // 基础显示高度
  489. // 根据图片和容器的宽高比决定缩放方式
  490. if (ratio > containerRatio) {
  491. baseW = container.width; // 宽度占满容器
  492. baseH = container.width / ratio; // 高度按比例缩放
  493. } else {
  494. baseH = container.height; // 高度占满容器
  495. baseW = container.height * ratio; // 宽度按比例缩放
  496. }
  497. // 计算覆盖裁剪框所需的缩放比例
  498. const scaleW = cropBox.width / baseW; // 宽度缩放比例
  499. const scaleH = cropBox.height / baseH; // 高度缩放比例
  500. const scale = Math.max(scaleW, scaleH); // 取最大缩放比例
  501. // 设置图片显示尺寸
  502. imageSize.width = toPixel(baseW * scale); // 计算最终显示宽度
  503. imageSize.height = toPixel(baseH * scale); // 计算最终显示高度
  504. }
  505. // 调整图片边界的函数,确保图片完全覆盖裁剪框
  506. function adjustBounds() {
  507. // 如果图片未加载,直接返回
  508. if (!imageInfo.isLoaded) return;
  509. // 获取旋转后的图片有效尺寸
  510. const rotatedSize = getRotatedImageSize();
  511. // 计算图片中心点坐标
  512. const centerX = container.width / 2 + transform.translateX; // 图片中心 x 坐标
  513. const centerY = container.height / 2 + transform.translateY; // 图片中心 y 坐标
  514. // 计算旋转后图片四个边界坐标
  515. const imgLeft = centerX - rotatedSize.width / 2; // 图片左边界
  516. const imgRight = centerX + rotatedSize.width / 2; // 图片右边界
  517. const imgTop = centerY - rotatedSize.height / 2; // 图片上边界
  518. const imgBottom = centerY + rotatedSize.height / 2; // 图片下边界
  519. // 计算裁剪框四个边界坐标
  520. const cropLeft = cropBox.x; // 裁剪框左边界
  521. const cropRight = cropBox.x + cropBox.width; // 裁剪框右边界
  522. const cropTop = cropBox.y; // 裁剪框上边界
  523. const cropBottom = cropBox.y + cropBox.height; // 裁剪框下边界
  524. // 获取当前位移值
  525. let x = transform.translateX; // 当前水平位移
  526. let y = transform.translateY; // 当前垂直位移
  527. // 水平方向边界调整
  528. if (imgLeft > cropLeft) {
  529. x -= imgLeft - cropLeft; // 如果图片左边界超出裁剪框,向左调整
  530. } else if (imgRight < cropRight) {
  531. x += cropRight - imgRight; // 如果图片右边界不足,向右调整
  532. }
  533. // 垂直方向边界调整
  534. if (imgTop > cropTop) {
  535. y -= imgTop - cropTop; // 如果图片上边界超出裁剪框,向上调整
  536. } else if (imgBottom < cropBottom) {
  537. y += cropBottom - imgBottom; // 如果图片下边界不足,向下调整
  538. }
  539. // 应用调整后的位移值
  540. transform.translateX = toPixel(x); // 更新水平位移
  541. transform.translateY = toPixel(y); // 更新垂直位移
  542. }
  543. // 开始调整裁剪框尺寸的函数
  544. function onResizeStart(e: TouchEvent, direction: string) {
  545. // 设置调整状态
  546. touch.isTouching = true; // 标记正在触摸
  547. touch.mode = "resizing"; // 设置为调整尺寸模式
  548. touch.direction = direction; // 记录调整方向(tl/tr/bl/br)
  549. isResizing.value = true; // 标记正在调整尺寸
  550. showGuideLines.value = true; // 显示九宫格辅助线
  551. // 如果是单指触摸,记录初始状态
  552. if (e.touches.length == 1) {
  553. touch.startX = e.touches[0].clientX; // 记录起始 x 坐标
  554. touch.startY = e.touches[0].clientY; // 记录起始 y 坐标
  555. touch.startCropBoxWidth = cropBox.width; // 记录起始裁剪框宽度
  556. touch.startCropBoxHeight = cropBox.height; // 记录起始裁剪框高度
  557. }
  558. }
  559. // 处理调整裁剪框尺寸移动的函数
  560. function onResizeMove(e: TouchEvent) {
  561. // 如果组件不在触摸状态或不是调整模式,直接返回
  562. if (!touch.isTouching || touch.mode != "resizing") return;
  563. // 如果是单指触摸
  564. if (e.touches.length == 1) {
  565. // 计算位移差
  566. const dx = e.touches[0].clientX - touch.startX; // 水平位移差
  567. const dy = e.touches[0].clientY - touch.startY; // 垂直位移差
  568. const MIN_SIZE = 50; // 最小裁剪框尺寸
  569. // 保存当前裁剪框的固定锚点坐标
  570. let anchorX: number = 0; // 固定不动的锚点坐标
  571. let anchorY: number = 0; // 固定不动的锚点坐标
  572. let newX = cropBox.x; // 新的 x 坐标
  573. let newY = cropBox.y; // 新的 y 坐标
  574. let newW = cropBox.width; // 新的宽度
  575. let newH = cropBox.height; // 新的高度
  576. // 根据拖拽方向计算新尺寸,同时确定固定锚点
  577. switch (touch.direction) {
  578. case "tl": // 左上角拖拽,固定右下角
  579. anchorX = cropBox.x + cropBox.width; // 右边界固定
  580. anchorY = cropBox.y + cropBox.height; // 下边界固定
  581. newW = anchorX - (cropBox.x + dx); // 根据移动距离计算新宽度
  582. newH = anchorY - (cropBox.y + dy); // 根据移动距离计算新高度
  583. newX = anchorX - newW; // 根据新宽度计算新 x 坐标
  584. newY = anchorY - newH; // 根据新高度计算新 y 坐标
  585. break;
  586. case "tr": // 右上角拖拽,固定左下角
  587. anchorX = cropBox.x; // 左边界固定
  588. anchorY = cropBox.y + cropBox.height; // 下边界固定
  589. newW = cropBox.width + dx; // 宽度增加
  590. newH = anchorY - (cropBox.y + dy); // 根据移动距离计算新高度
  591. newX = anchorX; // x 坐标不变
  592. newY = anchorY - newH; // 根据新高度计算新 y 坐标
  593. break;
  594. case "bl": // 左下角拖拽,固定右上角
  595. anchorX = cropBox.x + cropBox.width; // 右边界固定
  596. anchorY = cropBox.y; // 上边界固定
  597. newW = anchorX - (cropBox.x + dx); // 根据移动距离计算新宽度
  598. newH = cropBox.height + dy; // 高度增加
  599. newX = anchorX - newW; // 根据新宽度计算新 x 坐标
  600. newY = anchorY; // y 坐标不变
  601. break;
  602. case "br": // 右下角拖拽,固定左上角
  603. anchorX = cropBox.x; // 左边界固定
  604. anchorY = cropBox.y; // 上边界固定
  605. newW = cropBox.width + dx; // 宽度增加
  606. newH = cropBox.height + dy; // 高度增加
  607. newX = anchorX; // x 坐标不变
  608. newY = anchorY; // y 坐标不变
  609. break;
  610. }
  611. // 确保尺寸不小于最小值,并相应调整坐标
  612. if (newW < MIN_SIZE) {
  613. newW = MIN_SIZE;
  614. // 根据拖拽方向调整坐标
  615. if (touch.direction == "tl" || touch.direction == "bl") {
  616. newX = anchorX - newW; // 左侧拖拽时调整 x 坐标
  617. }
  618. }
  619. if (newH < MIN_SIZE) {
  620. newH = MIN_SIZE;
  621. // 根据拖拽方向调整坐标
  622. if (touch.direction == "tl" || touch.direction == "tr") {
  623. newY = anchorY - newH; // 上侧拖拽时调整 y 坐标
  624. }
  625. }
  626. // 确保裁剪框在容器边界内
  627. newX = toPixel(Math.max(0, Math.min(newX, container.width - newW)));
  628. newY = toPixel(Math.max(0, Math.min(newY, container.height - newH)));
  629. // 当位置受限时,调整尺寸以保持锚点位置
  630. if (newX == 0 && (touch.direction == "tl" || touch.direction == "bl")) {
  631. newW = anchorX; // 左边界贴边时,调整宽度
  632. }
  633. if (newY == 0 && (touch.direction == "tl" || touch.direction == "tr")) {
  634. newH = anchorY; // 上边界贴边时,调整高度
  635. }
  636. if (
  637. newX + newW >= container.width &&
  638. (touch.direction == "tr" || touch.direction == "br")
  639. ) {
  640. newW = container.width - newX; // 右边界贴边时,调整宽度
  641. }
  642. if (
  643. newY + newH >= container.height &&
  644. (touch.direction == "bl" || touch.direction == "br")
  645. ) {
  646. newH = container.height - newY; // 下边界贴边时,调整高度
  647. }
  648. // 应用计算结果
  649. cropBox.x = toPixel(newX);
  650. cropBox.y = toPixel(newY);
  651. cropBox.width = toPixel(newW);
  652. cropBox.height = toPixel(newH);
  653. // 无论是否达到边界,都更新起始坐标,确保下次计算的连续性
  654. touch.startX = e.touches[0].clientX; // 更新起始 x 坐标
  655. touch.startY = e.touches[0].clientY; // 更新起始 y 坐标
  656. }
  657. }
  658. // 居中并调整图片和裁剪框的函数
  659. function centerAndAdjust() {
  660. // 如果图片未加载,直接返回
  661. if (!imageInfo.isLoaded) return;
  662. // 获取当前图片尺寸
  663. const currentW = imageSize.width; // 当前图片宽度
  664. const currentH = imageSize.height; // 当前图片高度
  665. // 计算裁剪框缩放比例
  666. const scaleX = cropBox.width / touch.startCropBoxWidth; // 水平缩放比例
  667. const scaleY = cropBox.height / touch.startCropBoxHeight; // 垂直缩放比例
  668. const cropScale = Math.max(scaleX, scaleY); // 取最大缩放比例
  669. // 计算图片反向缩放比例
  670. let imgScale = 1 / cropScale; // 图片缩放倍数(与裁剪框缩放相反)
  671. // 计算调整后的图片尺寸
  672. let newW = currentW * imgScale; // 新的图片宽度
  673. let newH = currentH * imgScale; // 新的图片高度
  674. // 获取旋转后的图片有效尺寸,用于正确计算覆盖裁剪框的最小尺寸
  675. const getRotatedSize = (w: number, h: number): Size => {
  676. const angle = ((rotate.value % 360) + 360) % 360;
  677. if (angle == 90 || angle == 270) {
  678. return { width: h, height: w }; // 旋转90度/270度时宽高交换
  679. }
  680. return { width: w, height: h };
  681. };
  682. // 获取调整后图片的旋转有效尺寸
  683. const rotatedSize = getRotatedSize(newW, newH);
  684. // 确保图片能完全覆盖裁剪框(使用旋转后的有效尺寸)
  685. const minScaleW = cropBox.width / rotatedSize.width; // 宽度最小缩放比例
  686. const minScaleH = cropBox.height / rotatedSize.height; // 高度最小缩放比例
  687. const minScale = Math.max(minScaleW, minScaleH); // 取最大值确保完全覆盖
  688. // 如果需要进一步放大图片
  689. if (minScale > 1) {
  690. imgScale *= minScale; // 调整缩放倍数
  691. newW = currentW * imgScale; // 重新计算宽度
  692. newH = currentH * imgScale; // 重新计算高度
  693. }
  694. // 应用 maxScale 限制,保持图片比例
  695. const maxW = container.width * props.maxScale; // 最大宽度限制
  696. const maxH = container.height * props.maxScale; // 最大高度限制
  697. // 计算统一的最大缩放约束
  698. const maxScaleW = maxW / newW; // 最大宽度缩放比例
  699. const maxScaleH = maxH / newH; // 最大高度缩放比例
  700. const maxScaleConstraint = Math.min(maxScaleW, maxScaleH, 1); // 最大缩放约束
  701. // 应用最大缩放约束,保持比例
  702. newW = newW * maxScaleConstraint; // 应用最大缩放限制
  703. newH = newH * maxScaleConstraint; // 应用最大缩放限制
  704. // 应用新的图片尺寸
  705. imageSize.width = toPixel(newW); // 更新图片显示宽度
  706. imageSize.height = toPixel(newH); // 更新图片显示高度
  707. // 将裁剪框居中显示
  708. cropBox.x = toPixel((container.width - cropBox.width) / 2); // 水平居中
  709. cropBox.y = toPixel((container.height - cropBox.height) / 2); // 垂直居中
  710. // 重置图片位移到居中位置
  711. transform.translateX = 0; // 重置水平位移
  712. transform.translateY = 0; // 重置垂直位移
  713. // 调整图片边界
  714. adjustBounds(); // 确保图片完全覆盖裁剪框
  715. }
  716. // 处理调整尺寸结束事件的函数
  717. function onResizeEnd() {
  718. // 重置触摸状态
  719. touch.isTouching = false; // 标记触摸结束
  720. touch.mode = ""; // 清空触摸模式
  721. touch.direction = ""; // 清空调整方向
  722. isResizing.value = false; // 标记停止调整尺寸
  723. // 执行居中和调整
  724. centerAndAdjust(); // 重新调整图片和裁剪框
  725. // 延迟隐藏辅助线
  726. setTimeout(() => {
  727. showGuideLines.value = false; // 隐藏九宫格辅助线
  728. }, 200); // 200ms 后隐藏
  729. }
  730. // 处理图片触摸开始事件的函数
  731. function onTouchStart(e: TouchEvent) {
  732. // 如果组件图片未加载,直接返回
  733. if (!imageInfo.isLoaded) return;
  734. // 设置触摸状态
  735. touch.isTouching = true; // 标记正在触摸
  736. touch.mode = "image"; // 设置触摸模式为图片操作
  737. // 根据触摸点数量判断操作类型
  738. if (e.touches.length == 1) {
  739. // 单指拖拽模式
  740. touch.startX = e.touches[0].clientX; // 记录起始 x 坐标
  741. touch.startY = e.touches[0].clientY; // 记录起始 y 坐标
  742. touch.startTranslateX = transform.translateX; // 记录起始水平位移
  743. touch.startTranslateY = transform.translateY; // 记录起始垂直位移
  744. } else if (e.touches.length == 2) {
  745. // 双指缩放模式
  746. const t1 = e.touches[0]; // 第一个触摸点
  747. const t2 = e.touches[1]; // 第二个触摸点
  748. // 计算两个触摸点之间的初始距离
  749. touch.startDistance = Math.sqrt(
  750. Math.pow(t2.clientX - t1.clientX, 2) + Math.pow(t2.clientY - t1.clientY, 2)
  751. );
  752. // 记录触摸开始时的图片尺寸
  753. touch.startImageWidth = imageSize.width; // 起始图片宽度
  754. touch.startImageHeight = imageSize.height; // 起始图片高度
  755. // 计算并记录缩放中心点(两个触摸点的中点)
  756. touch.startX = (t1.clientX + t2.clientX) / 2; // 缩放中心 x 坐标
  757. touch.startY = (t1.clientY + t2.clientY) / 2; // 缩放中心 y 坐标
  758. // 记录触摸开始时的位移状态
  759. touch.startTranslateX = transform.translateX; // 起始水平位移
  760. touch.startTranslateY = transform.translateY; // 起始垂直位移
  761. }
  762. }
  763. // 处理图片触摸移动事件的函数
  764. function onTouchMove(e: TouchEvent) {
  765. if (!touch.isTouching) return;
  766. if (touch.mode == "resizing") {
  767. onResizeMove(e);
  768. return;
  769. }
  770. // 根据触摸点数量判断操作类型
  771. if (e.touches.length == 1) {
  772. // 单指拖拽模式
  773. const dx = e.touches[0].clientX - touch.startX; // 计算水平位移差
  774. const dy = e.touches[0].clientY - touch.startY; // 计算垂直位移差
  775. // 更新图片位移
  776. transform.translateX = toPixel(touch.startTranslateX + dx); // 应用水平位移
  777. transform.translateY = toPixel(touch.startTranslateY + dy); // 应用垂直位移
  778. } else if (e.touches.length == 2) {
  779. // 双指缩放模式
  780. const t1 = e.touches[0]; // 第一个触摸点
  781. const t2 = e.touches[1]; // 第二个触摸点
  782. // 计算当前两个触摸点之间的距离
  783. const distance = Math.sqrt(
  784. Math.pow(t2.clientX - t1.clientX, 2) + Math.pow(t2.clientY - t1.clientY, 2)
  785. );
  786. // 计算缩放倍数(当前距离 / 初始距离)
  787. const scale = distance / touch.startDistance;
  788. // 计算缩放后的新尺寸
  789. const newW = touch.startImageWidth * scale; // 新宽度
  790. const newH = touch.startImageHeight * scale; // 新高度
  791. // 获取尺寸约束条件
  792. const minSize = getMinImageSizeForPinch(); // 最小尺寸限制(专门用于双指缩放)
  793. const maxW = container.width * props.maxScale; // 最大宽度限制
  794. const maxH = container.height * props.maxScale; // 最大高度限制
  795. // 计算统一的缩放约束,保持图片比例
  796. const minScaleW = minSize.width / newW; // 最小宽度缩放比例
  797. const minScaleH = minSize.height / newH; // 最小高度缩放比例
  798. const maxScaleW = maxW / newW; // 最大宽度缩放比例
  799. const maxScaleH = maxH / newH; // 最大高度缩放比例
  800. // 取最严格的约束条件,确保图片不变形
  801. const minScale = Math.max(minScaleW, minScaleH); // 最小缩放约束
  802. const maxScale = Math.min(maxScaleW, maxScaleH); // 最大缩放约束
  803. const finalScale = Math.max(minScale, Math.min(maxScale, 1)); // 最终统一缩放比例
  804. // 应用统一的缩放比例,保持图片原始比例
  805. const finalW = newW * finalScale; // 最终宽度
  806. const finalH = newH * finalScale; // 最终高度
  807. // 计算当前缩放中心点
  808. const centerX = (t1.clientX + t2.clientX) / 2; // 缩放中心 x 坐标
  809. const centerY = (t1.clientY + t2.clientY) / 2; // 缩放中心 y 坐标
  810. // 计算尺寸变化量
  811. const dw = finalW - touch.startImageWidth; // 宽度变化量
  812. const dh = finalH - touch.startImageHeight; // 高度变化量
  813. // 计算位移补偿,使缩放围绕触摸中心进行
  814. const offsetX = ((centerX - container.width / 2) * dw) / (2 * touch.startImageWidth); // 水平位移补偿
  815. const offsetY = ((centerY - container.height / 2) * dh) / (2 * touch.startImageHeight); // 垂直位移补偿
  816. // 更新图片尺寸和位移
  817. imageSize.width = toPixel(finalW); // 应用新宽度
  818. imageSize.height = toPixel(finalH); // 应用新高度
  819. transform.translateX = toPixel(touch.startTranslateX - offsetX); // 应用补偿后的水平位移
  820. transform.translateY = toPixel(touch.startTranslateY - offsetY); // 应用补偿后的垂直位移
  821. }
  822. }
  823. // 处理图片触摸结束事件的函数
  824. function onTouchEnd() {
  825. if (touch.mode == "resizing") {
  826. onResizeEnd();
  827. return;
  828. }
  829. // 重置触摸状态
  830. touch.isTouching = false; // 标记触摸结束
  831. touch.mode = ""; // 清空触摸模式
  832. // 调整图片边界确保完全覆盖裁剪框
  833. adjustBounds(); // 执行边界调整
  834. }
  835. // 重置裁剪器到初始状态的函数
  836. function reset() {
  837. // 重新初始化裁剪框
  838. initCrop(); // 恢复裁剪框到初始位置和尺寸
  839. // 重置翻转状态
  840. flipHorizontal.value = false; // 重置水平翻转状态
  841. flipVertical.value = false; // 重置垂直翻转状态
  842. rotate.value = 0; // 重置旋转角度
  843. // 重置图片位移
  844. transform.translateX = 0;
  845. transform.translateY = 0;
  846. // 根据图片加载状态进行不同处理
  847. if (imageInfo.isLoaded) {
  848. setInitialImageSize(); // 重新设置图片初始尺寸
  849. adjustBounds(); // 调整图片边界
  850. } else {
  851. // 如果图片未加载,重置所有状态
  852. imageSize.width = toPixel(0); // 重置图片显示宽度
  853. imageSize.height = toPixel(0); // 重置图片显示高度
  854. transform.translateX = toPixel(0); // 重置水平位移
  855. transform.translateY = toPixel(0); // 重置垂直位移
  856. }
  857. }
  858. // 是否显示
  859. const visible = ref(false);
  860. // 图片地址
  861. const imageUrl = ref("");
  862. // 打开裁剪器
  863. function open(url: string) {
  864. visible.value = true;
  865. nextTick(() => {
  866. imageUrl.value = url;
  867. });
  868. }
  869. // 关闭裁剪器
  870. function close() {
  871. visible.value = false;
  872. }
  873. // 重新选择图片
  874. function chooseImage() {
  875. uni.chooseImage({
  876. count: 1,
  877. sizeType: ["original", "compressed"],
  878. sourceType: ["album", "camera"],
  879. success: (res) => {
  880. if (res.tempFilePaths.length > 0) {
  881. open(res.tempFilePaths[0]);
  882. }
  883. }
  884. });
  885. }
  886. // 处理图片加载完成事件的函数
  887. function onImageLoaded(e: UniImageLoadEvent) {
  888. // 更新图片原始尺寸信息
  889. imageInfo.width = e.detail.width; // 保存图片原始宽度
  890. imageInfo.height = e.detail.height; // 保存图片原始高度
  891. imageInfo.isLoaded = true; // 标记图片已加载
  892. reset(); // 重置裁剪框
  893. // 触发加载完成事件
  894. emit("load", e); // 向父组件发送加载事件
  895. }
  896. // 切换水平翻转状态的函数
  897. function toggleHorizontalFlip() {
  898. flipHorizontal.value = !flipHorizontal.value; // 切换水平翻转状态
  899. }
  900. // 切换垂直翻转状态的函数
  901. function toggleVerticalFlip() {
  902. flipVertical.value = !flipVertical.value; // 切换垂直翻转状态
  903. }
  904. // 90度旋转
  905. function rotate90() {
  906. rotate.value -= 90; // 旋转90度(逆时针)
  907. // 如果图片已加载,检查旋转后是否还能覆盖裁剪框
  908. if (imageInfo.isLoaded) {
  909. // 获取旋转后的有效尺寸
  910. const rotatedSize = getRotatedImageSize();
  911. // 检查旋转后的有效尺寸是否能完全覆盖裁剪框
  912. const scaleW = cropBox.width / rotatedSize.width; // 宽度需要的缩放比例
  913. const scaleH = cropBox.height / rotatedSize.height; // 高度需要的缩放比例
  914. const requiredScale = Math.max(scaleW, scaleH); // 取最大比例确保完全覆盖
  915. // 如果需要放大图片(旋转后尺寸不够覆盖裁剪框)
  916. if (requiredScale > 1) {
  917. // 同比例放大图片尺寸
  918. imageSize.width = toPixel(imageSize.width * requiredScale);
  919. imageSize.height = toPixel(imageSize.height * requiredScale);
  920. }
  921. // 调整边界确保图片完全覆盖裁剪框
  922. adjustBounds();
  923. }
  924. }
  925. // 执行裁剪转图片
  926. async function toPng(): Promise<string> {
  927. return new Promise((resolve) => {
  928. uni.createCanvasContextAsync({
  929. id: canvasId,
  930. component: proxy,
  931. success: (context: CanvasContext) => {
  932. // 获取设备像素比
  933. const dpr = getDevicePixelRatio();
  934. // 获取绘图上下文
  935. const ctx = context.getContext("2d")!;
  936. // 设置宽高
  937. ctx!.canvas.width = cropBox.width * dpr;
  938. ctx!.canvas.height = cropBox.height * dpr;
  939. // #ifdef APP
  940. ctx!.reset();
  941. // #endif
  942. // #ifndef APP
  943. ctx!.clearRect(0, 0, cropBox.width * dpr, cropBox.height * dpr);
  944. // #endif
  945. // 创建图片
  946. let img: Image;
  947. // 微信小程序环境创建图片
  948. // #ifdef MP-WEIXIN || APP-HARMONY
  949. img = context.createImage();
  950. // #endif
  951. // 其他环境创建图片
  952. // #ifndef MP-WEIXIN || APP-HARMONY
  953. img = new Image();
  954. // #endif
  955. // 设置图片源并在加载完成后绘制
  956. img.src = imageUrl.value;
  957. img.onload = () => {
  958. let x: number;
  959. let y: number;
  960. // 根据旋转角度计算裁剪位置
  961. switch (Math.abs(rotate.value) % 360) {
  962. case 270:
  963. // 旋转270度时的位置计算
  964. x = (imageSize.width - cropBox.height) / 2 - transform.translateY;
  965. y = (imageSize.height + cropBox.width) / 2 + transform.translateX;
  966. break;
  967. case 180:
  968. // 旋转180度时的位置计算
  969. x = (imageSize.width + cropBox.width) / 2 + transform.translateX;
  970. y = (imageSize.height + cropBox.height) / 2 + transform.translateY;
  971. break;
  972. case 90:
  973. // 旋转90度时的位置计算
  974. x = (imageSize.width + cropBox.height) / 2 + transform.translateY;
  975. y = (imageSize.height - cropBox.width) / 2 - transform.translateX;
  976. break;
  977. default:
  978. // 不旋转时的位置计算
  979. x = (imageSize.width - cropBox.width) / 2 - transform.translateX;
  980. y = (imageSize.height - cropBox.height) / 2 - transform.translateY;
  981. break;
  982. }
  983. if (x < 0) {
  984. x = 0;
  985. }
  986. if (y < 0) {
  987. y = 0;
  988. }
  989. // 图片旋转
  990. ctx!.rotate((rotate.value * Math.PI) / 180);
  991. // 绘制图片
  992. ctx!.drawImage(
  993. img,
  994. -x * dpr,
  995. -y * dpr,
  996. imageSize.width * dpr,
  997. imageSize.height * dpr
  998. );
  999. setTimeout(() => {
  1000. canvasToPng(canvasRef.value!).then((url) => {
  1001. emit("crop", url);
  1002. resolve(url);
  1003. });
  1004. }, 10);
  1005. };
  1006. }
  1007. });
  1008. });
  1009. }
  1010. defineExpose({
  1011. open,
  1012. close,
  1013. chooseImage,
  1014. toPng
  1015. });
  1016. </script>
  1017. <style lang="scss" scoped>
  1018. .cl-cropper {
  1019. @apply bg-black absolute left-0 top-0 w-full h-full;
  1020. z-index: 510;
  1021. &__image {
  1022. @apply absolute top-0 left-0 flex items-center justify-center w-full h-full;
  1023. @apply pointer-events-none;
  1024. &-inner {
  1025. @apply transition-none;
  1026. .no-dragging {
  1027. @apply duration-300;
  1028. transition-property: transform;
  1029. }
  1030. }
  1031. }
  1032. &__mask {
  1033. @apply absolute top-0 left-0 w-full h-full z-10 pointer-events-none;
  1034. &-item {
  1035. @apply absolute;
  1036. background-color: rgba(0, 0, 0, 0.4);
  1037. }
  1038. }
  1039. &__crop-box {
  1040. @apply absolute overflow-visible;
  1041. z-index: 10;
  1042. }
  1043. &__crop-area {
  1044. @apply relative w-full h-full overflow-visible duration-200 pointer-events-none;
  1045. @apply border border-solid;
  1046. border-color: rgba(255, 255, 255, 0.5);
  1047. &.is-resizing {
  1048. @apply border-primary-500;
  1049. }
  1050. }
  1051. &__guide-lines {
  1052. @apply flex justify-center items-center;
  1053. @apply absolute top-0 left-0 w-full h-full pointer-events-none opacity-0 duration-200;
  1054. &.is-show {
  1055. @apply opacity-100;
  1056. }
  1057. }
  1058. &__guide-line {
  1059. @apply absolute bg-white opacity-70;
  1060. &--h1 {
  1061. @apply top-1/3 left-0 w-full;
  1062. height: 1px;
  1063. }
  1064. &--h2 {
  1065. @apply top-2/3 left-0 w-full;
  1066. height: 1px;
  1067. }
  1068. &--v1 {
  1069. @apply left-1/3 top-0 h-full;
  1070. width: 1px;
  1071. }
  1072. &--v2 {
  1073. @apply left-2/3 top-0 h-full;
  1074. width: 1px;
  1075. }
  1076. }
  1077. &__guide-text {
  1078. @apply absolute flex flex-row items-center justify-center;
  1079. }
  1080. &__corner-indicator {
  1081. @apply border-white border-solid border-b-transparent border-l-transparent absolute duration-200;
  1082. width: 20px;
  1083. height: 20px;
  1084. border-width: 1px;
  1085. }
  1086. &__drag-point {
  1087. @apply absolute duration-200 flex items-center justify-center overflow-visible;
  1088. width: 40px;
  1089. height: 40px;
  1090. &--tl {
  1091. top: 0;
  1092. left: 0;
  1093. .cl-cropper__corner-indicator {
  1094. transform: rotate(-90deg);
  1095. left: -1px;
  1096. top: -1px;
  1097. }
  1098. }
  1099. &--tr {
  1100. top: 0;
  1101. right: 0;
  1102. .cl-cropper__corner-indicator {
  1103. transform: rotate(0deg);
  1104. right: -1px;
  1105. top: -1px;
  1106. }
  1107. }
  1108. &--bl {
  1109. bottom: 0;
  1110. left: 0;
  1111. .cl-cropper__corner-indicator {
  1112. transform: rotate(180deg);
  1113. bottom: -1px;
  1114. left: -1px;
  1115. }
  1116. }
  1117. &--br {
  1118. bottom: 0;
  1119. right: 0;
  1120. .cl-cropper__corner-indicator {
  1121. transform: rotate(90deg);
  1122. bottom: -1px;
  1123. right: 0-1px;
  1124. }
  1125. }
  1126. }
  1127. &__op {
  1128. @apply absolute left-0 bottom-0 w-full flex flex-row justify-between;
  1129. z-index: 30;
  1130. height: 40px;
  1131. &-item {
  1132. @apply flex flex-row justify-center items-center flex-1 h-full;
  1133. }
  1134. }
  1135. &__canvas {
  1136. @apply absolute top-0;
  1137. left: -10000px;
  1138. }
  1139. }
  1140. </style>