cl-cropper.uvue 40 KB

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