cl-cropper.uvue 37 KB

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