cl-cropper.uvue 40 KB

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