index.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679
  1. <template>
  2. <div
  3. class="image-previewer"
  4. :class="[isMobile && 'image-previewer-h5']"
  5. >
  6. <div
  7. ref="image"
  8. class="image-wrapper"
  9. @touchstart.stop="handleTouchStart"
  10. @touchmove.stop="handleTouchMove"
  11. @touchend.stop="handleTouchEnd"
  12. @touchcancel.stop="handleTouchCancel"
  13. @wheel.stop="handleWheel"
  14. >
  15. <ul
  16. ref="ulRef"
  17. class="image-list"
  18. :style="{
  19. width: `${imageList.length * 100}%`,
  20. transform: `translateX(-${
  21. (currentImageIndex * 100) / imageList.length
  22. }%)`,
  23. transition: '0.5s',
  24. }"
  25. >
  26. <li
  27. v-for="(item, index) in imageList"
  28. :key="index"
  29. class="image-item"
  30. >
  31. <ImageItem
  32. :zoom="zoom"
  33. :rotate="rotate"
  34. :src="getImageUrl(item)"
  35. :messageItem="item"
  36. :class="[isUniFrameWork ? 'image-item' : '']"
  37. />
  38. </li>
  39. </ul>
  40. </div>
  41. <div
  42. v-show="isPC"
  43. class="icon icon-close"
  44. @click="close"
  45. >
  46. <Icon
  47. :file="iconClose"
  48. width="16px"
  49. height="16px"
  50. />
  51. </div>
  52. <div
  53. v-if="isPC && currentImageIndex > 0"
  54. class="image-button image-button-left"
  55. @click="goPrev"
  56. >
  57. <Icon :file="iconArrowLeft" />
  58. </div>
  59. <div
  60. v-if="isPC && currentImageIndex < imageList.length - 1"
  61. class="image-button image-button-right"
  62. @click="goNext"
  63. >
  64. <Icon :file="iconArrowLeft" />
  65. </div>
  66. <div :class="['actions-bar', isMobile && 'actions-bar-h5']">
  67. <div
  68. v-if="isPC"
  69. class="icon-zoom-in"
  70. @click="zoomIn"
  71. >
  72. <Icon
  73. :file="iconZoomIn"
  74. width="27px"
  75. height="27px"
  76. />
  77. </div>
  78. <div
  79. v-if="isPC"
  80. class="icon-zoom-out"
  81. @click="zoomOut"
  82. >
  83. <Icon
  84. :file="iconZoomOut"
  85. width="27px"
  86. height="27px"
  87. />
  88. </div>
  89. <div
  90. v-if="isPC"
  91. class="icon-refresh-left"
  92. @click="rotateLeft"
  93. >
  94. <Icon
  95. :file="iconRotateLeft"
  96. width="27px"
  97. height="27px"
  98. />
  99. </div>
  100. <div
  101. v-if="isPC"
  102. class="icon-refresh-right"
  103. @click="rotateRight"
  104. >
  105. <Icon
  106. :file="iconRotateRight"
  107. width="27px"
  108. height="27px"
  109. />
  110. </div>
  111. <span class="image-counter">
  112. {{ currentImageIndex + 1 }} / {{ imageList.length }}
  113. </span>
  114. </div>
  115. <div
  116. class="save"
  117. @click.stop.prevent="save"
  118. >
  119. <Icon
  120. :file="iconDownload"
  121. width="20px"
  122. height="20px"
  123. />
  124. </div>
  125. </div>
  126. </template>
  127. <script setup lang="ts">
  128. import { ref, watchEffect, onMounted, onUnmounted, withDefaults } from '../../../adapter-vue';
  129. import { IMessageModel, TUITranslateService } from '@tencentcloud/chat-uikit-engine';
  130. import { TUIGlobal, getPlatform } from '@tencentcloud/universal-api';
  131. import Icon from '../../common/Icon.vue';
  132. import iconClose from '../../../assets/icon/icon-close.svg';
  133. import iconArrowLeft from '../../../assets/icon/icon-arrow-left.svg';
  134. import iconZoomIn from '../../../assets/icon/zoom-in.svg';
  135. import iconZoomOut from '../../../assets/icon/zoom-out.svg';
  136. import iconRotateLeft from '../../../assets/icon/rotate-left.svg';
  137. import iconRotateRight from '../../../assets/icon/rotate-right.svg';
  138. import iconDownload from '../../../assets/icon/download.svg';
  139. import ImageItem from './image-item.vue';
  140. import { Toast, TOAST_TYPE } from '../../common/Toast/index';
  141. import { isPC, isMobile, isUniFrameWork } from '../../../utils/env';
  142. interface touchesPosition {
  143. pageX1?: number;
  144. pageY1?: number;
  145. pageX2?: number;
  146. pageY2?: number;
  147. }
  148. const props = withDefaults(
  149. defineProps<{
  150. imageList: IMessageModel[];
  151. currentImage: IMessageModel;
  152. }>(),
  153. {
  154. imageList: () => ([] as IMessageModel[]),
  155. messageItem: () => ({} as IMessageModel),
  156. },
  157. );
  158. const imageFormatMap = new Map([
  159. [1, 'jpg'],
  160. [2, 'gif'],
  161. [3, 'png'],
  162. [4, 'bmp'],
  163. ]);
  164. const emit = defineEmits(['close']);
  165. const zoom = ref(1);
  166. const rotate = ref(0);
  167. const minZoom = ref(0.1);
  168. const currentImageIndex = ref(0);
  169. const image = ref();
  170. const ulRef = ref();
  171. // touch
  172. let startX = 0;
  173. const touchStore = {} as touchesPosition;
  174. let moveFlag = false;
  175. let twoTouchesFlag = false;
  176. let timer: number | null = null;
  177. watchEffect(() => {
  178. currentImageIndex.value = props.imageList.findIndex((message: any) => {
  179. return message.ID === props?.currentImage?.ID;
  180. });
  181. });
  182. const isNumber = (value: any) => {
  183. return typeof value === 'number' && isFinite(value);
  184. };
  185. const handleTouchStart = (e: any) => {
  186. e.preventDefault();
  187. moveInit(e);
  188. twoTouchesInit(e);
  189. };
  190. const handleTouchMove = (e: any) => {
  191. e.preventDefault();
  192. moveFlag = true;
  193. if (e.touches && e.touches.length === 2) {
  194. twoTouchesFlag = true;
  195. handleTwoTouches(e);
  196. }
  197. };
  198. const handleTouchEnd = (e: any) => {
  199. e.preventDefault();
  200. e.stopPropagation();
  201. let moveEndX = 0;
  202. let X = 0;
  203. if (twoTouchesFlag) {
  204. if (!timer) {
  205. twoTouchesFlag = false;
  206. delete touchStore.pageX2;
  207. delete touchStore.pageY2;
  208. timer = setTimeout(() => {
  209. timer = null;
  210. }, 200);
  211. }
  212. return;
  213. }
  214. // H5 touch move to left to go to prev image
  215. // H5 touch move to right to go to next image
  216. if (timer === null) {
  217. switch (moveFlag) {
  218. // touch event
  219. case true:
  220. moveEndX = e?.changedTouches[0]?.pageX;
  221. X = moveEndX - startX;
  222. if (X > 100) {
  223. goPrev();
  224. } else if (X < -100) {
  225. goNext();
  226. }
  227. break;
  228. // click event
  229. case false:
  230. close();
  231. break;
  232. }
  233. timer = setTimeout(() => {
  234. timer = null;
  235. }, 200);
  236. }
  237. };
  238. const handleTouchCancel = () => {
  239. twoTouchesFlag = false;
  240. delete touchStore.pageX1;
  241. delete touchStore.pageY1;
  242. };
  243. const handleWheel = (e: any) => {
  244. e.preventDefault();
  245. if (Math.abs(e.deltaX) !== 0 && Math.abs(e.deltaY) !== 0) return;
  246. let scale = zoom.value;
  247. scale += e.deltaY * (e.ctrlKey ? -0.01 : 0.002);
  248. scale = Math.min(Math.max(0.125, scale), 4);
  249. zoom.value = scale;
  250. };
  251. const moveInit = (e: any) => {
  252. startX = e?.changedTouches[0]?.pageX;
  253. moveFlag = false;
  254. };
  255. const twoTouchesInit = (e: any) => {
  256. const touch1 = e?.touches[0];
  257. const touch2 = e?.touches[1];
  258. touchStore.pageX1 = touch1?.pageX;
  259. touchStore.pageY1 = touch1?.pageY;
  260. if (touch2) {
  261. touchStore.pageX2 = touch2?.pageX;
  262. touchStore.pageY2 = touch2?.pageY;
  263. }
  264. };
  265. const handleTwoTouches = (e: any) => {
  266. const touch1 = e?.touches[0];
  267. const touch2 = e?.touches[1];
  268. if (touch2) {
  269. if (!isNumber(touchStore.pageX2)) {
  270. touchStore.pageX2 = touch2.pageX;
  271. }
  272. if (!isNumber(touchStore.pageY2)) {
  273. touchStore.pageY2 = touch2.pageY;
  274. }
  275. }
  276. const getDistance = (
  277. startX: number,
  278. startY: number,
  279. stopX: number,
  280. stopY: number,
  281. ) => {
  282. return Math.hypot(stopX - startX, stopY - startY);
  283. };
  284. if (
  285. !isNumber(touchStore.pageX1)
  286. || !isNumber(touchStore.pageY1)
  287. || !isNumber(touchStore.pageX2)
  288. || !isNumber(touchStore.pageY2)
  289. ) {
  290. return;
  291. }
  292. const touchZoom
  293. = getDistance(touch1.pageX, touch1.pageY, touch2.pageX, touch2.pageY)
  294. / getDistance(
  295. touchStore.pageX1 as number,
  296. touchStore.pageY1 as number,
  297. touchStore.pageX2 as number,
  298. touchStore.pageY2 as number,
  299. );
  300. zoom.value = Math.min(Math.max(0.5, zoom.value * touchZoom), 4);
  301. };
  302. onMounted(() => {
  303. // web: close on esc keydown
  304. document?.addEventListener
  305. && document?.addEventListener('keydown', handleEsc);
  306. });
  307. const handleEsc = (e: any) => {
  308. e.preventDefault();
  309. if (e?.keyCode === 27) {
  310. close();
  311. }
  312. };
  313. const zoomIn = () => {
  314. zoom.value += 0.1;
  315. };
  316. const zoomOut = () => {
  317. zoom.value
  318. = zoom.value - 0.1 > minZoom.value ? zoom.value - 0.1 : minZoom.value;
  319. };
  320. const close = () => {
  321. emit('close');
  322. };
  323. const rotateLeft = () => {
  324. rotate.value -= 90;
  325. };
  326. const rotateRight = () => {
  327. rotate.value += 90;
  328. };
  329. const goNext = () => {
  330. currentImageIndex.value < props.imageList.length - 1
  331. && currentImageIndex.value++;
  332. initStyle();
  333. };
  334. const goPrev = () => {
  335. currentImageIndex.value > 0 && currentImageIndex.value--;
  336. initStyle();
  337. };
  338. const initStyle = () => {
  339. zoom.value = 1;
  340. rotate.value = 0;
  341. };
  342. const getImageUrl = (message: IMessageModel) => {
  343. if (isPC) {
  344. return message?.payload?.imageInfoArray[0]?.url;
  345. } else {
  346. return message?.payload?.imageInfoArray[2]?.url;
  347. }
  348. };
  349. const save = () => {
  350. const imageMessage = props.imageList[
  351. currentImageIndex.value
  352. ] as IMessageModel;
  353. const imageSrc = imageMessage?.payload?.imageInfoArray[0]?.url;
  354. if (!imageSrc) {
  355. Toast({
  356. message: TUITranslateService.t('component.图片 url 不存在'),
  357. type: TOAST_TYPE.ERROR,
  358. });
  359. return;
  360. }
  361. switch (getPlatform()) {
  362. case 'wechat':
  363. // Get the user's current settings and get the album permissions
  364. TUIGlobal.getSetting({
  365. success: (res: any) => {
  366. if (!res?.authSetting['scope.writePhotosAlbum']) {
  367. TUIGlobal.authorize({
  368. scope: 'scope.writePhotosAlbum',
  369. success() {
  370. downloadImgInUni(imageSrc);
  371. },
  372. fail() {
  373. TUIGlobal.showModal({
  374. title: '您已拒绝获取相册权限',
  375. content: '是否进入权限管理,调整授权?',
  376. success: (res: any) => {
  377. if (res.confirm) {
  378. // Call up the client applet settings interface and return the operation results set by the user.
  379. // Ask the user to authorize again.
  380. TUIGlobal.openSetting({
  381. success: (res: any) => {
  382. console.log(res.authSetting);
  383. },
  384. });
  385. } else if (res.cancel) {
  386. return Toast({
  387. message: TUITranslateService.t('component.已取消'),
  388. type: TOAST_TYPE.ERROR,
  389. });
  390. }
  391. },
  392. });
  393. },
  394. });
  395. } else {
  396. // If you already have album permission, save directly to the album
  397. downloadImgInUni(imageSrc);
  398. }
  399. },
  400. fail: () => {
  401. Toast({
  402. message: TUITranslateService.t('component.获取权限失败'),
  403. type: TOAST_TYPE.ERROR,
  404. });
  405. },
  406. });
  407. break;
  408. case 'app':
  409. downloadImgInUni(imageSrc);
  410. break;
  411. default:
  412. downloadImgInWeb(imageSrc);
  413. break;
  414. }
  415. };
  416. const downloadImgInUni = (src: string) => {
  417. TUIGlobal.showLoading({
  418. title: '大图提取中',
  419. });
  420. TUIGlobal.downloadFile({
  421. url: src,
  422. success: function (res: any) {
  423. TUIGlobal.hideLoading();
  424. TUIGlobal.saveImageToPhotosAlbum({
  425. filePath: res.tempFilePath,
  426. success: () => {
  427. Toast({
  428. message: TUITranslateService.t('component.已保存至相册'),
  429. type: TOAST_TYPE.SUCCESS,
  430. });
  431. },
  432. });
  433. },
  434. fail: function () {
  435. TUIGlobal.hideLoading();
  436. Toast({
  437. message: TUITranslateService.t('component.图片下载失败'),
  438. type: TOAST_TYPE.ERROR,
  439. });
  440. },
  441. });
  442. };
  443. const downloadImgInWeb = (src: string) => {
  444. const option: any = {
  445. mode: 'cors',
  446. headers: new Headers({
  447. 'Content-Type': 'application/x-www-form-urlencoded',
  448. }),
  449. };
  450. const imageMessage = props.imageList[
  451. currentImageIndex.value
  452. ] as IMessageModel;
  453. const imageFormat: number = imageMessage?.payload?.imageFormat;
  454. if (!imageFormatMap.has(imageFormat)) {
  455. Toast({
  456. message: TUITranslateService.t('component.暂不支持下载此类型图片'),
  457. type: TOAST_TYPE.ERROR,
  458. });
  459. return;
  460. }
  461. // If the browser supports fetch, use blob to download, so as to avoid the browser clicking the a tag and jumping to the preview of the new page
  462. if ((window as any).fetch) {
  463. fetch(src, option)
  464. .then(res => res.blob())
  465. .then((blob) => {
  466. const a = document.createElement('a');
  467. const url = window.URL.createObjectURL(blob);
  468. a.href = url;
  469. a.download = url + '.' + imageFormatMap.get(imageFormat);
  470. a.click();
  471. });
  472. } else {
  473. const a = document.createElement('a');
  474. a.href = src;
  475. a.target = '_blank';
  476. a.download = src + '.' + imageFormatMap.get(imageFormat);
  477. a.click();
  478. }
  479. };
  480. onUnmounted(() => {
  481. document?.removeEventListener
  482. && document?.removeEventListener('keydown', handleEsc);
  483. });
  484. </script>
  485. <style lang="scss" scoped>
  486. @import "../../../assets/styles/common";
  487. .actions-bar {
  488. display: flex;
  489. justify-content: space-around;
  490. align-items: center;
  491. position: absolute;
  492. bottom: 5%;
  493. padding: 12px;
  494. border-radius: 6px;
  495. background: rgba(255, 255, 255, 0.8);
  496. &-h5 {
  497. padding: 6px;
  498. }
  499. .icon {
  500. position: static;
  501. font-size: 24px;
  502. cursor: pointer;
  503. width: 27px;
  504. height: 27px;
  505. margin: 5px;
  506. }
  507. .icon-zoom-in,
  508. .icon-zoom-out,
  509. .icon-refresh-left,
  510. .icon-refresh-right {
  511. cursor: pointer;
  512. user-select: none;
  513. padding: 5px;
  514. }
  515. }
  516. .image-previewer {
  517. position: fixed;
  518. z-index: 101;
  519. width: 100vw;
  520. height: 100vh;
  521. background: rgba(#000, 0.3);
  522. top: 0;
  523. left: 0;
  524. display: flex;
  525. flex-direction: column;
  526. align-items: center;
  527. user-select: none;
  528. .image-wrapper {
  529. position: relative;
  530. width: 100%;
  531. height: 100%;
  532. display: flex;
  533. justify-content: center;
  534. align-items: center;
  535. overflow: hidden;
  536. }
  537. .image-list {
  538. position: absolute;
  539. height: 100%;
  540. left: 0;
  541. padding: 0;
  542. margin: 0;
  543. display: flex;
  544. flex-direction: row;
  545. place-content: center center;
  546. .image-item {
  547. width: 100%;
  548. height: 100%;
  549. display: flex;
  550. align-items: center;
  551. justify-content: center;
  552. overflow: hidden;
  553. }
  554. }
  555. .image-preview {
  556. max-width: 100%;
  557. max-height: 100%;
  558. transition: transform 0.1s ease 0s;
  559. pointer-events: auto;
  560. }
  561. .image-button {
  562. display: flex;
  563. flex-direction: column;
  564. min-width: auto;
  565. justify-content: center;
  566. align-items: center;
  567. position: absolute;
  568. cursor: pointer;
  569. width: 40px;
  570. height: 40px;
  571. border-radius: 50%;
  572. top: calc(50% - 20px);
  573. background: rgba(255, 255, 255, 0.8);
  574. &-left {
  575. left: 10px;
  576. }
  577. &-right {
  578. right: 10px;
  579. transform: rotate(180deg);
  580. }
  581. .icon {
  582. position: absolute;
  583. bottom: 0;
  584. top: 0;
  585. left: 0;
  586. right: 0;
  587. margin: auto;
  588. line-height: 40px;
  589. display: flex;
  590. flex-direction: column;
  591. min-width: auto;
  592. justify-content: center;
  593. align-items: center;
  594. }
  595. }
  596. .icon-close {
  597. position: absolute;
  598. cursor: pointer;
  599. border-radius: 50%;
  600. top: 3%;
  601. right: 3%;
  602. padding: 10px;
  603. background: rgba(255, 255, 255, 0.8);
  604. display: flex;
  605. &::before,
  606. &::after {
  607. background-color: #444;
  608. }
  609. }
  610. }
  611. .image-previewer-h5 {
  612. width: 100%;
  613. height: 100%;
  614. background: #000;
  615. display: flex;
  616. flex-direction: column;
  617. }
  618. .save {
  619. cursor: pointer;
  620. display: flex;
  621. justify-content: space-around;
  622. align-items: center;
  623. position: absolute;
  624. bottom: 5%;
  625. right: 5%;
  626. padding: 12px;
  627. border-radius: 6px;
  628. background: rgba(255, 255, 255, 0.8);
  629. }
  630. .image-counter {
  631. background: rgba(20, 18, 20, 0.53);
  632. padding: 3px 5px;
  633. margin: 5px;
  634. border-radius: 3px;
  635. color: #fff;
  636. }
  637. </style>