cl-cropper.uvue 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953
  1. <template>
  2. <view
  3. class="cl-cropper"
  4. :class="[
  5. {
  6. 'is-disabled': disabled
  7. },
  8. pt.className
  9. ]"
  10. @touchmove.stop.prevent
  11. >
  12. <view
  13. class="cl-cropper__container"
  14. :class="[pt.inner?.className]"
  15. @touchstart="onImageTouchStart"
  16. @touchmove="onImageTouchMove"
  17. @touchend="onImageTouchEnd"
  18. @touchcancel="onImageTouchEnd"
  19. >
  20. <!-- 图片容器 - 可拖拽和缩放的图片区域 -->
  21. <view class="cl-cropper__image-container">
  22. <image
  23. class="cl-cropper__image"
  24. :class="[pt.image?.className]"
  25. :src="src"
  26. :style="imageStyle"
  27. @load="onImageLoaded"
  28. ></image>
  29. </view>
  30. <!-- 遮罩层 - 覆盖裁剪框外的区域 -->
  31. <view class="cl-cropper__mask">
  32. <view
  33. v-for="(item, index) in ['top', 'right', 'bottom', 'left']"
  34. :key="index"
  35. :class="`cl-cropper__mask-item cl-cropper__mask-item--${item}`"
  36. :style="maskStyle[item]!"
  37. ></view>
  38. </view>
  39. <!-- 裁剪框 - 可拖拽和调整大小的选择区域 -->
  40. <view
  41. class="cl-cropper__crop-box"
  42. :class="[pt.cropBox?.className]"
  43. :style="cropBoxStyle"
  44. >
  45. <!-- 裁剪区域 - 内部可继续拖拽图片 -->
  46. <view class="cl-cropper__crop-area" :class="{ 'is-resizing': isResizing }">
  47. <!-- 九宫格辅助线 - 在调整大小时显示 -->
  48. <view
  49. class="cl-cropper__guide-lines"
  50. :class="{
  51. 'is-show': showGuideLines
  52. }"
  53. >
  54. <view class="cl-cropper__guide-line cl-cropper__guide-line--h1"></view>
  55. <view class="cl-cropper__guide-line cl-cropper__guide-line--h2"></view>
  56. <view class="cl-cropper__guide-line cl-cropper__guide-line--v1"></view>
  57. <view class="cl-cropper__guide-line cl-cropper__guide-line--v2"></view>
  58. </view>
  59. <view
  60. v-for="item in ['tl', 'tr', 'bl', 'br']"
  61. :key="item"
  62. class="cl-cropper__drag-point"
  63. :class="[`cl-cropper__drag-point--${item}`]"
  64. @touchstart="onResizeStart($event as TouchEvent, item)"
  65. >
  66. <view class="cl-cropper__corner-indicator"></view>
  67. </view>
  68. </view>
  69. </view>
  70. <!-- 操作按钮组 -->
  71. <view class="cl-cropper__buttons" v-if="showButtons">
  72. <cl-button
  73. type="light"
  74. size="small"
  75. :pt="{ className: pt.button?.className }"
  76. @tap="resetCropper"
  77. >
  78. 重置
  79. </cl-button>
  80. <cl-button
  81. type="primary"
  82. size="small"
  83. :pt="{ className: pt.button?.className }"
  84. @tap="performCrop"
  85. >
  86. 裁剪
  87. </cl-button>
  88. </view>
  89. </view>
  90. </view>
  91. </template>
  92. <script setup lang="ts">
  93. // 导入 Vue 3 组合式 API 相关函数
  94. import { computed, ref, reactive, onMounted, type PropType } from "vue";
  95. // 导入透传属性类型定义
  96. import type { PassThroughProps } from "../../types";
  97. // 导入工具函数:解析透传属性和页面钩子
  98. import { parsePt, usePage } from "@/cool";
  99. // 定义容器尺寸类型
  100. type Container = {
  101. height: number; // 容器高度
  102. width: number; // 容器宽度
  103. };
  104. // 定义遮罩层样式类型
  105. type MaskStyle = {
  106. top: UTSJSONObject; // 上方遮罩样式
  107. right: UTSJSONObject; // 右侧遮罩样式
  108. bottom: UTSJSONObject; // 下方遮罩样式
  109. left: UTSJSONObject; // 左侧遮罩样式
  110. };
  111. // 定义图片信息类型
  112. type ImageInfo = {
  113. width: number; // 图片原始宽度
  114. height: number; // 图片原始高度
  115. isLoaded: boolean; // 图片是否已加载
  116. };
  117. // 定义图片变换类型
  118. type Transform = {
  119. translateX: number; // 水平位移
  120. translateY: number; // 垂直位移
  121. };
  122. // 定义尺寸类型
  123. type Size = {
  124. width: number; // 宽度
  125. height: number; // 高度
  126. };
  127. // 定义裁剪框类型
  128. type CropBox = {
  129. x: number; // 裁剪框 x 坐标
  130. y: number; // 裁剪框 y 坐标
  131. width: number; // 裁剪框宽度
  132. height: number; // 裁剪框高度
  133. };
  134. // 定义触摸状态类型
  135. type TouchState = {
  136. startX: number; // 触摸开始 x 坐标
  137. startY: number; // 触摸开始 y 坐标
  138. startDistance: number; // 双指触摸开始距离
  139. startImageWidth: number; // 触摸开始时图片宽度
  140. startImageHeight: number; // 触摸开始时图片高度
  141. startTranslateX: number; // 触摸开始时水平位移
  142. startTranslateY: number; // 触摸开始时垂直位移
  143. startCropBoxWidth: number; // 触摸开始时裁剪框宽度
  144. startCropBoxHeight: number; // 触摸开始时裁剪框高度
  145. isTouching: boolean; // 是否正在触摸
  146. mode: string; // 触摸模式:image/resizing
  147. direction: string; // 调整方向:tl/tr/bl/br
  148. };
  149. // 定义组件选项
  150. defineOptions({
  151. name: "cl-cropper" // 组件名称
  152. });
  153. // 定义组件属性
  154. const props = defineProps({
  155. // 透传样式配置对象
  156. pt: {
  157. type: Object, // 属性类型为对象
  158. default: () => ({}) // 默认值为空对象
  159. },
  160. // 图片源地址
  161. src: {
  162. type: String, // 属性类型为字符串
  163. default: "" // 默认值为空字符串
  164. },
  165. // 裁剪框初始宽度(像素)
  166. cropWidth: {
  167. type: Number, // 属性类型为数字
  168. default: 300 // 默认值为 300 像素
  169. },
  170. // 裁剪框初始高度(像素)
  171. cropHeight: {
  172. type: Number, // 属性类型为数字
  173. default: 300 // 默认值为 300 像素
  174. },
  175. // 图片最大缩放倍数
  176. maxScale: {
  177. type: Number, // 属性类型为数字
  178. default: 3 // 默认值为 3 倍
  179. },
  180. // 图片最小缩放倍数
  181. minScale: {
  182. type: Number, // 属性类型为数字
  183. default: 0.5 // 默认值为 0.5 倍
  184. },
  185. // 是否显示底部操作按钮
  186. showButtons: {
  187. type: Boolean, // 属性类型为布尔值
  188. default: true // 默认值为显示
  189. },
  190. // 输出图片质量(0-1 之间)
  191. quality: {
  192. type: Number, // 属性类型为数字
  193. default: 0.9 // 默认值为 0.9
  194. },
  195. // 输出图片格式:jpg 或 png
  196. format: {
  197. type: String as PropType<"jpg" | "png">, // 属性类型为联合字符串类型
  198. default: "jpg" // 默认值为 jpg 格式
  199. },
  200. // 是否禁用所有交互操作
  201. disabled: {
  202. type: Boolean, // 属性类型为布尔值
  203. default: false // 默认值为不禁用
  204. }
  205. });
  206. // 定义事件发射器,支持 crop、load、error 事件
  207. const emit = defineEmits(["crop", "load", "error"]);
  208. // 获取页面实例,用于获取视图尺寸
  209. const page = usePage();
  210. // 定义透传样式配置类型
  211. type PassThrough = {
  212. className?: string; // 组件根元素类名
  213. inner?: PassThroughProps; // 内部容器透传属性
  214. image?: PassThroughProps; // 图片元素透传属性
  215. cropBox?: PassThroughProps; // 裁剪框透传属性
  216. button?: PassThroughProps; // 按钮透传属性
  217. };
  218. // 解析透传样式配置的计算属性
  219. const pt = computed(() => parsePt<PassThrough>(props.pt));
  220. // 创建容器尺寸响应式对象
  221. const container = reactive<Container>({
  222. height: page.getViewHeight(), // 获取视图高度
  223. width: page.getViewWidth() // 获取视图宽度
  224. });
  225. // 创建图片信息响应式对象
  226. const imageInfo = reactive<ImageInfo>({
  227. width: 0, // 初始宽度为 0
  228. height: 0, // 初始高度为 0
  229. isLoaded: false // 初始加载状态为未加载
  230. });
  231. // 创建图片变换响应式对象
  232. const transform = reactive<Transform>({
  233. translateX: 0, // 初始水平位移为 0
  234. translateY: 0 // 初始垂直位移为 0
  235. });
  236. // 创建图片尺寸响应式对象
  237. const imageSize = reactive<Size>({
  238. width: 0, // 初始显示宽度为 0
  239. height: 0 // 初始显示高度为 0
  240. });
  241. // 创建裁剪框响应式对象
  242. const cropBox = reactive<CropBox>({
  243. x: 0, // 初始 x 坐标为 0
  244. y: 0, // 初始 y 坐标为 0
  245. width: props.cropWidth, // 使用传入的裁剪框宽度
  246. height: props.cropHeight // 使用传入的裁剪框高度
  247. });
  248. // 创建触摸状态响应式对象
  249. const touch = reactive<TouchState>({
  250. startX: 0, // 初始触摸 x 坐标为 0
  251. startY: 0, // 初始触摸 y 坐标为 0
  252. startDistance: 0, // 初始双指距离为 0
  253. startImageWidth: 0, // 初始图片宽度为 0
  254. startImageHeight: 0, // 初始图片高度为 0
  255. startTranslateX: 0, // 初始水平位移为 0
  256. startTranslateY: 0, // 初始垂直位移为 0
  257. startCropBoxWidth: 0, // 初始裁剪框宽度为 0
  258. startCropBoxHeight: 0, // 初始裁剪框高度为 0
  259. isTouching: false, // 初始触摸状态为未触摸
  260. mode: "", // 初始触摸模式为空
  261. direction: "" // 初始调整方向为空
  262. });
  263. // 是否正在调整裁剪框大小的响应式引用
  264. const isResizing = ref(false);
  265. // 是否显示九宫格辅助线的响应式引用
  266. const showGuideLines = ref(false);
  267. // 计算图片样式的计算属性
  268. const imageStyle = computed(() => {
  269. // 创建基础样式对象
  270. const style = {
  271. transform: `translate(${transform.translateX}px, ${transform.translateY}px)`, // 设置图片位移变换
  272. height: imageSize.height + "px", // 设置图片显示高度
  273. width: imageSize.width + "px" // 设置图片显示宽度
  274. };
  275. // 如果不在触摸状态,添加过渡动画
  276. if (!touch.isTouching) {
  277. style["transitionDuration"] = "0.3s"; // 设置过渡动画时长
  278. }
  279. // 返回样式对象
  280. return style;
  281. });
  282. // 计算裁剪框样式的计算属性
  283. const cropBoxStyle = computed(() => {
  284. // 返回裁剪框定位和尺寸样式
  285. return {
  286. left: `${cropBox.x}px`, // 设置裁剪框左边距
  287. top: `${cropBox.y}px`, // 设置裁剪框上边距
  288. width: `${cropBox.width}px`, // 设置裁剪框宽度
  289. height: `${cropBox.height}px` // 设置裁剪框高度
  290. };
  291. });
  292. // 计算遮罩层样式的计算属性
  293. const maskStyle = computed<MaskStyle>(() => {
  294. // 返回四个方向的遮罩样式
  295. return {
  296. // 上方遮罩样式
  297. top: {
  298. height: `${cropBox.y}px`, // 遮罩高度到裁剪框顶部
  299. width: `${cropBox.width}px`, // 遮罩宽度占满容器
  300. left: `${cropBox.x}px`
  301. },
  302. // 右侧遮罩样式
  303. right: {
  304. width: `${container.width - cropBox.x - cropBox.width}px`, // 遮罩宽度为容器宽度减去裁剪框右边距
  305. height: "100%", // 遮罩高度与裁剪框相同
  306. top: 0, // 遮罩顶部对齐裁剪框
  307. left: `${cropBox.x + cropBox.width}px` // 遮罩贴右边
  308. },
  309. // 下方遮罩样式
  310. bottom: {
  311. height: `${container.height - cropBox.y - cropBox.height}px`, // 遮罩高度为容器高度减去裁剪框下边距
  312. width: `${cropBox.width}px`, // 遮罩宽度占满容器
  313. bottom: 0, // 遮罩贴底部
  314. left: `${cropBox.x}px`
  315. },
  316. // 左侧遮罩样式
  317. left: {
  318. width: `${cropBox.x}px`, // 遮罩宽度到裁剪框左边
  319. height: "100%", // 遮罩高度与裁剪框相同
  320. left: 0
  321. }
  322. };
  323. });
  324. // 计算图片最小尺寸的函数
  325. function getMinImageSize(): Size {
  326. // 如果图片未加载或尺寸无效,返回零尺寸
  327. if (!imageInfo.isLoaded || imageInfo.width == 0 || imageInfo.height == 0) {
  328. return { width: 0, height: 0 }; // 返回空尺寸对象
  329. }
  330. // 计算图片宽高比
  331. const ratio = imageInfo.width / imageInfo.height;
  332. // 计算容器宽高比
  333. const containerRatio = container.width / container.height;
  334. // 声明基础显示尺寸变量
  335. let baseW: number; // 基础显示宽度
  336. let baseH: number; // 基础显示高度
  337. // 根据图片和容器的宽高比决定缩放方式
  338. if (ratio > containerRatio) {
  339. baseW = container.width; // 宽度占满容器
  340. baseH = container.width / ratio; // 高度按比例缩放
  341. } else {
  342. baseH = container.height; // 高度占满容器
  343. baseW = container.height * ratio; // 宽度按比例缩放
  344. }
  345. // 计算覆盖裁剪框所需的最小缩放比例
  346. const scaleW = cropBox.width / baseW; // 宽度缩放比例
  347. const scaleH = cropBox.height / baseH; // 高度缩放比例
  348. const minScale = Math.max(scaleW, scaleH); // 取最大缩放比例确保完全覆盖
  349. // 应用最小缩放限制,增加少量容差
  350. const finalScale = Math.max(props.minScale, minScale * 1.01);
  351. // 返回最终尺寸
  352. return {
  353. width: baseW * finalScale, // 计算最终宽度
  354. height: baseH * finalScale // 计算最终高度
  355. };
  356. }
  357. // 初始化裁剪框的函数
  358. function initCropBox() {
  359. // 设置裁剪框尺寸为传入的初始值
  360. cropBox.width = props.cropWidth; // 设置裁剪框宽度
  361. cropBox.height = props.cropHeight; // 设置裁剪框高度
  362. // 计算裁剪框居中位置
  363. cropBox.x = (container.width - cropBox.width) / 2; // 水平居中
  364. cropBox.y = (container.height - cropBox.height) / 2; // 垂直居中
  365. // 如果图片已加载,确保图片尺寸满足最小要求
  366. if (imageInfo.isLoaded) {
  367. const minSize = getMinImageSize(); // 获取最小尺寸
  368. // 如果当前尺寸小于最小尺寸,更新为最小尺寸
  369. if (imageSize.width < minSize.width || imageSize.height < minSize.height) {
  370. imageSize.width = minSize.width; // 更新图片显示宽度
  371. imageSize.height = minSize.height; // 更新图片显示高度
  372. }
  373. }
  374. }
  375. // 设置初始图片尺寸的函数
  376. function setInitialImageSize() {
  377. // 如果图片未加载或尺寸无效,直接返回
  378. if (!imageInfo.isLoaded || imageInfo.width == 0 || imageInfo.height == 0) {
  379. return; // 提前退出函数
  380. }
  381. // 计算图片宽高比
  382. const ratio = imageInfo.width / imageInfo.height;
  383. // 计算容器宽高比
  384. const containerRatio = container.width / container.height;
  385. // 声明基础显示尺寸变量
  386. let baseW: number; // 基础显示宽度
  387. let baseH: number; // 基础显示高度
  388. // 根据图片和容器的宽高比决定缩放方式
  389. if (ratio > containerRatio) {
  390. baseW = container.width; // 宽度占满容器
  391. baseH = container.width / ratio; // 高度按比例缩放
  392. } else {
  393. baseH = container.height; // 高度占满容器
  394. baseW = container.height * ratio; // 宽度按比例缩放
  395. }
  396. // 计算覆盖裁剪框所需的缩放比例
  397. const scaleW = cropBox.width / baseW; // 宽度缩放比例
  398. const scaleH = cropBox.height / baseH; // 高度缩放比例
  399. const scale = Math.max(scaleW, scaleH); // 取最大缩放比例
  400. // 设置图片显示尺寸
  401. imageSize.width = baseW * scale; // 计算最终显示宽度
  402. imageSize.height = baseH * scale; // 计算最终显示高度
  403. }
  404. // 调整图片边界的函数,确保图片完全覆盖裁剪框
  405. function adjustBounds() {
  406. // 如果图片未加载,直接返回
  407. if (!imageInfo.isLoaded) return;
  408. // 计算图片中心点坐标
  409. const centerX = container.width / 2 + transform.translateX; // 图片中心 x 坐标
  410. const centerY = container.height / 2 + transform.translateY; // 图片中心 y 坐标
  411. // 计算图片四个边界坐标
  412. const imgLeft = centerX - imageSize.width / 2; // 图片左边界
  413. const imgRight = centerX + imageSize.width / 2; // 图片右边界
  414. const imgTop = centerY - imageSize.height / 2; // 图片上边界
  415. const imgBottom = centerY + imageSize.height / 2; // 图片下边界
  416. // 计算裁剪框四个边界坐标
  417. const cropLeft = cropBox.x; // 裁剪框左边界
  418. const cropRight = cropBox.x + cropBox.width; // 裁剪框右边界
  419. const cropTop = cropBox.y; // 裁剪框上边界
  420. const cropBottom = cropBox.y + cropBox.height; // 裁剪框下边界
  421. // 获取当前位移值
  422. let x = transform.translateX; // 当前水平位移
  423. let y = transform.translateY; // 当前垂直位移
  424. // 水平方向边界调整
  425. if (imgLeft > cropLeft) {
  426. x -= imgLeft - cropLeft; // 如果图片左边界超出裁剪框,向左调整
  427. } else if (imgRight < cropRight) {
  428. x += cropRight - imgRight; // 如果图片右边界不足,向右调整
  429. }
  430. // 垂直方向边界调整
  431. if (imgTop > cropTop) {
  432. y -= imgTop - cropTop; // 如果图片上边界超出裁剪框,向上调整
  433. } else if (imgBottom < cropBottom) {
  434. y += cropBottom - imgBottom; // 如果图片下边界不足,向下调整
  435. }
  436. // 应用调整后的位移值
  437. transform.translateX = x; // 更新水平位移
  438. transform.translateY = y; // 更新垂直位移
  439. }
  440. // 处理图片加载完成事件的函数
  441. function onImageLoaded(e: UniImageLoadEvent) {
  442. // 更新图片原始尺寸信息
  443. imageInfo.width = e.detail.width; // 保存图片原始宽度
  444. imageInfo.height = e.detail.height; // 保存图片原始高度
  445. imageInfo.isLoaded = true; // 标记图片已加载
  446. // 执行初始化流程
  447. initCropBox(); // 初始化裁剪框位置和尺寸
  448. setInitialImageSize(); // 设置图片初始显示尺寸
  449. adjustBounds(); // 调整图片边界确保覆盖裁剪框
  450. // 触发加载完成事件
  451. emit("load", e); // 向父组件发送加载事件
  452. }
  453. // 开始调整裁剪框尺寸的函数
  454. function onResizeStart(e: TouchEvent, direction: string) {
  455. // 如果组件被禁用,直接返回
  456. if (props.disabled) return;
  457. // 阻止事件冒泡到图片容器
  458. e.stopPropagation(); // 避免触发图片的触摸事件
  459. // 设置调整状态
  460. touch.isTouching = true; // 标记正在触摸
  461. touch.mode = "resizing"; // 设置为调整尺寸模式
  462. touch.direction = direction; // 记录调整方向(tl/tr/bl/br)
  463. isResizing.value = true; // 标记正在调整尺寸
  464. showGuideLines.value = true; // 显示九宫格辅助线
  465. // 如果是单指触摸,记录初始状态
  466. if (e.touches.length == 1) {
  467. touch.startX = e.touches[0].clientX; // 记录起始 x 坐标
  468. touch.startY = e.touches[0].clientY; // 记录起始 y 坐标
  469. touch.startCropBoxWidth = cropBox.width; // 记录起始裁剪框宽度
  470. touch.startCropBoxHeight = cropBox.height; // 记录起始裁剪框高度
  471. }
  472. }
  473. // 处理调整裁剪框尺寸移动的函数
  474. function onResizeMove(e: TouchEvent) {
  475. // 如果组件被禁用、不在触摸状态或不是调整模式,直接返回
  476. if (props.disabled || !touch.isTouching || touch.mode != "resizing") return;
  477. // 阻止默认行为和事件冒泡
  478. e.preventDefault(); // 阻止页面滚动等默认行为
  479. e.stopPropagation(); // 阻止事件向上冒泡
  480. // 如果是单指触摸
  481. if (e.touches.length == 1) {
  482. // 计算位移差
  483. const dx = e.touches[0].clientX - touch.startX; // 水平位移差
  484. const dy = e.touches[0].clientY - touch.startY; // 垂直位移差
  485. const MIN_SIZE = 50; // 最小裁剪框尺寸
  486. // 初始化新的裁剪框参数
  487. let newX = cropBox.x; // 新的 x 坐标
  488. let newY = cropBox.y; // 新的 y 坐标
  489. let newW = cropBox.width; // 新的宽度
  490. let newH = cropBox.height; // 新的高度
  491. // 根据拖拽方向调整裁剪框
  492. switch (touch.direction) {
  493. case "tl": // 左上角拖拽
  494. newX = Math.max(0, cropBox.x + dx); // x 坐标向右移动
  495. newY = Math.max(0, cropBox.y + dy); // y 坐标向下移动
  496. newW = cropBox.width - dx; // 宽度相应减少
  497. newH = cropBox.height - dy; // 高度相应减少
  498. break;
  499. case "tr": // 右上角拖拽
  500. newY = Math.max(0, cropBox.y + dy); // y 坐标向下移动
  501. newW = cropBox.width + dx; // 宽度增加
  502. newH = cropBox.height - dy; // 高度减少
  503. break;
  504. case "bl": // 左下角拖拽
  505. newX = Math.max(0, cropBox.x + dx); // x 坐标向右移动
  506. newW = cropBox.width - dx; // 宽度减少
  507. newH = cropBox.height + dy; // 高度增加
  508. break;
  509. case "br": // 右下角拖拽
  510. newW = cropBox.width + dx; // 宽度增加
  511. newH = cropBox.height + dy; // 高度增加
  512. break;
  513. }
  514. // 验证新尺寸和位置的有效性
  515. const validSize = newW >= MIN_SIZE && newH >= MIN_SIZE; // 检查尺寸是否满足最小要求
  516. const inBounds =
  517. newX >= 0 && // 左边界不超出容器
  518. newY >= 0 && // 上边界不超出容器
  519. newX + newW <= container.width && // 右边界不超出容器
  520. newY + newH <= container.height; // 下边界不超出容器
  521. // 如果新参数有效,应用更改
  522. if (validSize && inBounds) {
  523. cropBox.x = newX; // 更新 x 坐标
  524. cropBox.y = newY; // 更新 y 坐标
  525. cropBox.width = newW; // 更新宽度
  526. cropBox.height = newH; // 更新高度
  527. // 更新起始坐标为当前位置,用于下次计算增量
  528. touch.startX = e.touches[0].clientX; // 更新起始 x 坐标
  529. touch.startY = e.touches[0].clientY; // 更新起始 y 坐标
  530. }
  531. }
  532. }
  533. // 居中并调整图片和裁剪框的函数
  534. function centerAndAdjust() {
  535. // 如果图片未加载,直接返回
  536. if (!imageInfo.isLoaded) return;
  537. // 获取当前图片尺寸
  538. const currentW = imageSize.width; // 当前图片宽度
  539. const currentH = imageSize.height; // 当前图片高度
  540. // 计算裁剪框缩放比例
  541. const scaleX = cropBox.width / touch.startCropBoxWidth; // 水平缩放比例
  542. const scaleY = cropBox.height / touch.startCropBoxHeight; // 垂直缩放比例
  543. const cropScale = Math.max(scaleX, scaleY); // 取最大缩放比例
  544. // 计算图片反向缩放比例
  545. let imgScale = 1 / cropScale; // 图片缩放倍数(与裁剪框缩放相反)
  546. // 计算调整后的图片尺寸
  547. let newW = currentW * imgScale; // 新的图片宽度
  548. let newH = currentH * imgScale; // 新的图片高度
  549. // 确保图片能完全覆盖裁剪框
  550. const minScaleW = cropBox.width / newW; // 宽度最小缩放比例
  551. const minScaleH = cropBox.height / newH; // 高度最小缩放比例
  552. const minScale = Math.max(minScaleW, minScaleH); // 取最大值确保完全覆盖
  553. // 如果需要进一步放大图片
  554. if (minScale > 1) {
  555. imgScale *= minScale; // 调整缩放倍数
  556. newW = currentW * imgScale; // 重新计算宽度
  557. newH = currentH * imgScale; // 重新计算高度
  558. }
  559. // 应用新的图片尺寸
  560. imageSize.width = newW; // 更新图片显示宽度
  561. imageSize.height = newH; // 更新图片显示高度
  562. // 将裁剪框居中显示
  563. cropBox.x = (container.width - cropBox.width) / 2; // 水平居中
  564. cropBox.y = (container.height - cropBox.height) / 2; // 垂直居中
  565. // 调整图片边界
  566. adjustBounds(); // 确保图片完全覆盖裁剪框
  567. }
  568. // 处理调整尺寸结束事件的函数
  569. function onResizeEnd() {
  570. // 重置触摸状态
  571. touch.isTouching = false; // 标记触摸结束
  572. touch.mode = ""; // 清空触摸模式
  573. touch.direction = ""; // 清空调整方向
  574. isResizing.value = false; // 标记停止调整尺寸
  575. // 执行居中和调整
  576. centerAndAdjust(); // 重新调整图片和裁剪框
  577. // 延迟隐藏辅助线
  578. setTimeout(() => {
  579. showGuideLines.value = false; // 隐藏九宫格辅助线
  580. }, 200); // 200ms 后隐藏
  581. }
  582. // 处理图片触摸开始事件的函数
  583. function onImageTouchStart(e: TouchEvent) {
  584. // 如果组件被禁用或图片未加载,直接返回
  585. if (props.disabled || !imageInfo.isLoaded) return;
  586. // 设置触摸状态
  587. touch.isTouching = true; // 标记正在触摸
  588. touch.mode = "image"; // 设置触摸模式为图片操作
  589. // 根据触摸点数量判断操作类型
  590. if (e.touches.length == 1) {
  591. // 单指拖拽模式
  592. touch.startX = e.touches[0].clientX; // 记录起始 x 坐标
  593. touch.startY = e.touches[0].clientY; // 记录起始 y 坐标
  594. touch.startTranslateX = transform.translateX; // 记录起始水平位移
  595. touch.startTranslateY = transform.translateY; // 记录起始垂直位移
  596. } else if (e.touches.length == 2) {
  597. // 双指缩放模式
  598. const t1 = e.touches[0]; // 第一个触摸点
  599. const t2 = e.touches[1]; // 第二个触摸点
  600. // 计算两个触摸点之间的初始距离
  601. touch.startDistance = Math.sqrt(
  602. Math.pow(t2.clientX - t1.clientX, 2) + Math.pow(t2.clientY - t1.clientY, 2)
  603. );
  604. // 记录触摸开始时的图片尺寸
  605. touch.startImageWidth = imageSize.width; // 起始图片宽度
  606. touch.startImageHeight = imageSize.height; // 起始图片高度
  607. // 计算并记录缩放中心点(两个触摸点的中点)
  608. touch.startX = (t1.clientX + t2.clientX) / 2; // 缩放中心 x 坐标
  609. touch.startY = (t1.clientY + t2.clientY) / 2; // 缩放中心 y 坐标
  610. // 记录触摸开始时的位移状态
  611. touch.startTranslateX = transform.translateX; // 起始水平位移
  612. touch.startTranslateY = transform.translateY; // 起始垂直位移
  613. }
  614. }
  615. // 处理图片触摸移动事件的函数
  616. function onImageTouchMove(e: TouchEvent) {
  617. if (touch.mode == "resizing") {
  618. onResizeMove(e);
  619. return;
  620. }
  621. // 如果组件被禁用、不在触摸状态或不是图片操作模式,直接返回
  622. if (props.disabled || !touch.isTouching || touch.mode != "image") return;
  623. // 阻止默认行为和事件冒泡
  624. e.preventDefault(); // 阻止页面滚动等默认行为
  625. e.stopPropagation(); // 阻止事件向上冒泡
  626. // 根据触摸点数量判断操作类型
  627. if (e.touches.length == 1) {
  628. // 单指拖拽模式
  629. const dx = e.touches[0].clientX - touch.startX; // 计算水平位移差
  630. const dy = e.touches[0].clientY - touch.startY; // 计算垂直位移差
  631. // 更新图片位移
  632. transform.translateX = touch.startTranslateX + dx; // 应用水平位移
  633. transform.translateY = touch.startTranslateY + dy; // 应用垂直位移
  634. } else if (e.touches.length == 2) {
  635. // 双指缩放模式
  636. const t1 = e.touches[0]; // 第一个触摸点
  637. const t2 = e.touches[1]; // 第二个触摸点
  638. // 计算当前两个触摸点之间的距离
  639. const distance = Math.sqrt(
  640. Math.pow(t2.clientX - t1.clientX, 2) + Math.pow(t2.clientY - t1.clientY, 2)
  641. );
  642. // 计算缩放倍数(当前距离 / 初始距离)
  643. const scale = distance / touch.startDistance;
  644. // 计算缩放后的新尺寸
  645. const newW = touch.startImageWidth * scale; // 新宽度
  646. const newH = touch.startImageHeight * scale; // 新高度
  647. // 获取尺寸约束条件
  648. const minSize = getMinImageSize(); // 最小尺寸限制
  649. const maxW = container.width * props.maxScale; // 最大宽度限制
  650. const maxH = container.height * props.maxScale; // 最大高度限制
  651. // 应用尺寸约束,确保在允许范围内
  652. const finalW = Math.max(minSize.width, Math.min(maxW, newW)); // 最终宽度
  653. const finalH = Math.max(minSize.height, Math.min(maxH, newH)); // 最终高度
  654. // 计算当前缩放中心点
  655. const centerX = (t1.clientX + t2.clientX) / 2; // 缩放中心 x 坐标
  656. const centerY = (t1.clientY + t2.clientY) / 2; // 缩放中心 y 坐标
  657. // 计算尺寸变化量
  658. const dw = finalW - touch.startImageWidth; // 宽度变化量
  659. const dh = finalH - touch.startImageHeight; // 高度变化量
  660. // 计算位移补偿,使缩放围绕触摸中心进行
  661. const offsetX = ((centerX - container.width / 2) * dw) / (2 * touch.startImageWidth); // 水平位移补偿
  662. const offsetY = ((centerY - container.height / 2) * dh) / (2 * touch.startImageHeight); // 垂直位移补偿
  663. // 更新图片尺寸和位移
  664. imageSize.width = finalW; // 应用新宽度
  665. imageSize.height = finalH; // 应用新高度
  666. transform.translateX = touch.startTranslateX - offsetX; // 应用补偿后的水平位移
  667. transform.translateY = touch.startTranslateY - offsetY; // 应用补偿后的垂直位移
  668. }
  669. }
  670. // 处理图片触摸结束事件的函数
  671. function onImageTouchEnd() {
  672. if (touch.mode == "resizing") {
  673. onResizeEnd();
  674. return;
  675. }
  676. // 重置触摸状态
  677. touch.isTouching = false; // 标记触摸结束
  678. touch.mode = ""; // 清空触摸模式
  679. // 调整图片边界确保完全覆盖裁剪框
  680. adjustBounds(); // 执行边界调整
  681. }
  682. // 重置裁剪器到初始状态的函数
  683. function resetCropper() {
  684. // 重新初始化裁剪框
  685. initCropBox(); // 恢复裁剪框到初始位置和尺寸
  686. // 根据图片加载状态进行不同处理
  687. if (imageInfo.isLoaded) {
  688. setInitialImageSize(); // 重新设置图片初始尺寸
  689. adjustBounds(); // 调整图片边界
  690. } else {
  691. // 如果图片未加载,重置所有状态
  692. imageSize.width = 0; // 重置图片显示宽度
  693. imageSize.height = 0; // 重置图片显示高度
  694. transform.translateX = 0; // 重置水平位移
  695. transform.translateY = 0; // 重置垂直位移
  696. }
  697. }
  698. // 执行裁剪操作的函数
  699. function performCrop() {
  700. // 检查图片是否已加载
  701. if (!imageInfo.isLoaded) {
  702. emit("error", "图片尚未加载完成,无法执行裁剪操作"); // 发送错误事件
  703. return; // 提前退出
  704. }
  705. }
  706. // 组件挂载时执行的钩子函数
  707. onMounted(() => {
  708. initCropBox(); // 初始化裁剪框
  709. });
  710. </script>
  711. <style lang="scss" scoped>
  712. .cl-cropper {
  713. @apply bg-black absolute left-0 top-0 w-full h-full;
  714. z-index: 100;
  715. &.is-disabled {
  716. @apply opacity-50;
  717. }
  718. &__container {
  719. @apply relative w-full h-full;
  720. }
  721. &__image-container {
  722. @apply absolute top-0 left-0 flex items-center justify-center w-full h-full;
  723. }
  724. &__mask {
  725. @apply absolute top-0 left-0 w-full h-full z-10 pointer-events-none;
  726. &-item {
  727. @apply absolute;
  728. background-color: rgba(0, 0, 0, 0.4);
  729. }
  730. }
  731. &__crop-box {
  732. @apply absolute overflow-visible pointer-events-none;
  733. z-index: 10;
  734. }
  735. &__crop-area {
  736. @apply relative w-full h-full overflow-visible duration-200 pointer-events-none;
  737. @apply border border-solid;
  738. border-color: rgba(255, 255, 255, 0.5);
  739. &.is-resizing {
  740. @apply border-primary-500;
  741. }
  742. }
  743. &__guide-lines {
  744. @apply absolute top-0 left-0 w-full h-full pointer-events-none opacity-0 duration-200;
  745. &.is-show {
  746. @apply opacity-100;
  747. }
  748. }
  749. &__guide-line {
  750. @apply absolute bg-white opacity-70;
  751. &--h1 {
  752. @apply top-1/3 left-0 w-full;
  753. height: 0.5px;
  754. }
  755. &--h2 {
  756. @apply top-2/3 left-0 w-full;
  757. height: 0.5px;
  758. }
  759. &--v1 {
  760. @apply left-1/3 top-0 h-full;
  761. width: 0.5px;
  762. }
  763. &--v2 {
  764. @apply left-2/3 top-0 h-full;
  765. width: 0.5px;
  766. }
  767. }
  768. &__corner-indicator {
  769. @apply border-white border-solid border-b-transparent border-l-transparent absolute duration-200;
  770. width: 20px;
  771. height: 20px;
  772. border-width: 1px;
  773. }
  774. &__drag-point {
  775. @apply absolute duration-200 flex items-center justify-center pointer-events-auto;
  776. width: 40px;
  777. height: 40px;
  778. &--tl {
  779. top: -20px;
  780. left: -20px;
  781. .cl-cropper__corner-indicator {
  782. transform: rotate(-90deg);
  783. left: 20px;
  784. top: 20px;
  785. }
  786. }
  787. &--tr {
  788. top: -20px;
  789. right: -20px;
  790. .cl-cropper__corner-indicator {
  791. transform: rotate(0deg);
  792. right: 20px;
  793. top: 20px;
  794. }
  795. }
  796. &--bl {
  797. bottom: -20px;
  798. left: -20px;
  799. .cl-cropper__corner-indicator {
  800. transform: rotate(180deg);
  801. bottom: 20px;
  802. left: 20px;
  803. }
  804. }
  805. &--br {
  806. bottom: -20px;
  807. right: -20px;
  808. .cl-cropper__corner-indicator {
  809. transform: rotate(90deg);
  810. bottom: 20px;
  811. right: 20px;
  812. }
  813. }
  814. }
  815. &__buttons {
  816. @apply absolute bottom-4 left-0 right-0 flex flex-row justify-center;
  817. }
  818. }
  819. </style>