cl-cropper.uvue 39 KB

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