index.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. import "./circled.css";
  2. import Cropper from "cropperjs";
  3. import { ElUpload } from "element-plus";
  4. import type { CSSProperties } from "vue";
  5. import { useEventListener } from "@vueuse/core";
  6. import { longpress } from "@/directives/longpress";
  7. import { useTippy, directive as tippy } from "vue-tippy";
  8. import {
  9. type PropType,
  10. ref,
  11. unref,
  12. computed,
  13. onMounted,
  14. onUnmounted,
  15. defineComponent
  16. } from "vue";
  17. import {
  18. delay,
  19. debounce,
  20. isArray,
  21. downloadByBase64,
  22. useResizeObserver
  23. } from "@pureadmin/utils";
  24. import {
  25. Reload,
  26. Upload,
  27. ArrowH,
  28. ArrowV,
  29. ArrowUp,
  30. ArrowDown,
  31. ArrowLeft,
  32. ChangeIcon,
  33. ArrowRight,
  34. RotateLeft,
  35. SearchPlus,
  36. RotateRight,
  37. SearchMinus,
  38. DownloadIcon
  39. } from "./svg";
  40. type Options = Cropper.Options;
  41. const defaultOptions: Options = {
  42. aspectRatio: 1,
  43. zoomable: true,
  44. zoomOnTouch: true,
  45. zoomOnWheel: true,
  46. cropBoxMovable: true,
  47. cropBoxResizable: true,
  48. toggleDragModeOnDblclick: true,
  49. autoCrop: true,
  50. background: true,
  51. highlight: true,
  52. center: true,
  53. responsive: true,
  54. restore: true,
  55. checkCrossOrigin: true,
  56. checkOrientation: true,
  57. scalable: true,
  58. modal: true,
  59. guides: true,
  60. movable: true,
  61. rotatable: true
  62. };
  63. const props = {
  64. src: { type: String, required: true },
  65. alt: { type: String },
  66. circled: { type: Boolean, default: false },
  67. /** 是否可以通过点击裁剪区域关闭右键弹出的功能菜单,默认 `true` */
  68. isClose: { type: Boolean, default: true },
  69. realTimePreview: { type: Boolean, default: true },
  70. height: { type: [String, Number], default: "360px" },
  71. crossorigin: {
  72. type: String as PropType<"" | "anonymous" | "use-credentials" | undefined>,
  73. default: undefined
  74. },
  75. imageStyle: { type: Object as PropType<CSSProperties>, default: () => ({}) },
  76. options: { type: Object as PropType<Options>, default: () => ({}) }
  77. };
  78. export default defineComponent({
  79. name: "ReCropper",
  80. props,
  81. setup(props, { attrs, emit }) {
  82. const tippyElRef = ref<ElRef<HTMLImageElement>>();
  83. const imgElRef = ref<ElRef<HTMLImageElement>>();
  84. const cropper = ref<Nullable<Cropper>>();
  85. const inCircled = ref(props.circled);
  86. const isInClose = ref(props.isClose);
  87. const inSrc = ref(props.src);
  88. const isReady = ref(false);
  89. const imgBase64 = ref();
  90. let scaleX = 1;
  91. let scaleY = 1;
  92. const debounceRealTimeCroppered = debounce(realTimeCroppered, 80);
  93. const getImageStyle = computed((): CSSProperties => {
  94. return {
  95. height: props.height,
  96. maxWidth: "100%",
  97. ...props.imageStyle
  98. };
  99. });
  100. const getClass = computed(() => {
  101. return [
  102. attrs.class,
  103. {
  104. ["re-circled"]: inCircled.value
  105. }
  106. ];
  107. });
  108. const iconClass = computed(() => {
  109. return [
  110. "p-[6px]",
  111. "h-[30px]",
  112. "w-[30px]",
  113. "outline-hidden",
  114. "rounded-[4px]",
  115. "cursor-pointer",
  116. "hover:bg-[rgba(0,0,0,0.06)]"
  117. ];
  118. });
  119. const getWrapperStyle = computed((): CSSProperties => {
  120. return { height: `${props.height}`.replace(/px/, "") + "px" };
  121. });
  122. onMounted(init);
  123. onUnmounted(() => {
  124. cropper.value?.destroy();
  125. isReady.value = false;
  126. cropper.value = null;
  127. imgBase64.value = "";
  128. scaleX = 1;
  129. scaleY = 1;
  130. });
  131. useResizeObserver(tippyElRef, () => handCropper("reset"));
  132. async function init() {
  133. const imgEl = unref(imgElRef);
  134. if (!imgEl) return;
  135. cropper.value = new Cropper(imgEl, {
  136. ...defaultOptions,
  137. ready: () => {
  138. isReady.value = true;
  139. realTimeCroppered();
  140. delay(400).then(() => emit("readied", cropper.value));
  141. },
  142. crop() {
  143. debounceRealTimeCroppered();
  144. },
  145. zoom() {
  146. debounceRealTimeCroppered();
  147. },
  148. cropmove() {
  149. debounceRealTimeCroppered();
  150. },
  151. ...props.options
  152. });
  153. }
  154. function realTimeCroppered() {
  155. props.realTimePreview && croppered();
  156. }
  157. function croppered() {
  158. if (!cropper.value) return;
  159. const canvas = inCircled.value
  160. ? getRoundedCanvas()
  161. : cropper.value.getCroppedCanvas();
  162. // https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLCanvasElement/toBlob
  163. canvas.toBlob(blob => {
  164. if (!blob) return;
  165. const fileReader: FileReader = new FileReader();
  166. fileReader.readAsDataURL(blob);
  167. fileReader.onloadend = e => {
  168. if (!e.target?.result || !blob) return;
  169. imgBase64.value = e.target.result;
  170. emit("cropper", {
  171. base64: e.target.result,
  172. blob,
  173. info: { size: blob.size, ...cropper.value.getData() }
  174. });
  175. };
  176. fileReader.onerror = () => {
  177. emit("error");
  178. };
  179. });
  180. }
  181. function getRoundedCanvas() {
  182. const sourceCanvas = cropper.value!.getCroppedCanvas();
  183. const canvas = document.createElement("canvas");
  184. const context = canvas.getContext("2d")!;
  185. const width = sourceCanvas.width;
  186. const height = sourceCanvas.height;
  187. canvas.width = width;
  188. canvas.height = height;
  189. context.imageSmoothingEnabled = true;
  190. context.drawImage(sourceCanvas, 0, 0, width, height);
  191. context.globalCompositeOperation = "destination-in";
  192. context.beginPath();
  193. context.arc(
  194. width / 2,
  195. height / 2,
  196. Math.min(width, height) / 2,
  197. 0,
  198. 2 * Math.PI,
  199. true
  200. );
  201. context.fill();
  202. return canvas;
  203. }
  204. function handCropper(event: string, arg?: number | Array<number>) {
  205. if (event === "scaleX") {
  206. scaleX = arg = scaleX === -1 ? 1 : -1;
  207. }
  208. if (event === "scaleY") {
  209. scaleY = arg = scaleY === -1 ? 1 : -1;
  210. }
  211. arg && isArray(arg)
  212. ? cropper.value?.[event]?.(...arg)
  213. : cropper.value?.[event]?.(arg);
  214. }
  215. function beforeUpload(file) {
  216. const reader = new FileReader();
  217. reader.readAsDataURL(file);
  218. inSrc.value = "";
  219. reader.onload = e => {
  220. inSrc.value = e.target?.result as string;
  221. };
  222. reader.onloadend = () => {
  223. init();
  224. };
  225. return false;
  226. }
  227. const menuContent = defineComponent({
  228. directives: {
  229. tippy,
  230. longpress
  231. },
  232. setup() {
  233. return () => (
  234. <div class="flex flex-wrap w-[60px] justify-between">
  235. <ElUpload
  236. accept="image/*"
  237. show-file-list={false}
  238. before-upload={beforeUpload}
  239. >
  240. <Upload
  241. class={iconClass.value}
  242. v-tippy={{
  243. content: "上传",
  244. placement: "left-start"
  245. }}
  246. />
  247. </ElUpload>
  248. <DownloadIcon
  249. class={iconClass.value}
  250. v-tippy={{
  251. content: "下载",
  252. placement: "right-start"
  253. }}
  254. onClick={() => downloadByBase64(imgBase64.value, "cropping.png")}
  255. />
  256. <ChangeIcon
  257. class={iconClass.value}
  258. v-tippy={{
  259. content: "圆形、矩形裁剪",
  260. placement: "left-start"
  261. }}
  262. onClick={() => {
  263. inCircled.value = !inCircled.value;
  264. realTimeCroppered();
  265. }}
  266. />
  267. <Reload
  268. class={iconClass.value}
  269. v-tippy={{
  270. content: "重置",
  271. placement: "right-start"
  272. }}
  273. onClick={() => handCropper("reset")}
  274. />
  275. <ArrowUp
  276. class={iconClass.value}
  277. v-tippy={{
  278. content: "上移(可长按)",
  279. placement: "left-start"
  280. }}
  281. v-longpress={[() => handCropper("move", [0, -10]), "0:100"]}
  282. />
  283. <ArrowDown
  284. class={iconClass.value}
  285. v-tippy={{
  286. content: "下移(可长按)",
  287. placement: "right-start"
  288. }}
  289. v-longpress={[() => handCropper("move", [0, 10]), "0:100"]}
  290. />
  291. <ArrowLeft
  292. class={iconClass.value}
  293. v-tippy={{
  294. content: "左移(可长按)",
  295. placement: "left-start"
  296. }}
  297. v-longpress={[() => handCropper("move", [-10, 0]), "0:100"]}
  298. />
  299. <ArrowRight
  300. class={iconClass.value}
  301. v-tippy={{
  302. content: "右移(可长按)",
  303. placement: "right-start"
  304. }}
  305. v-longpress={[() => handCropper("move", [10, 0]), "0:100"]}
  306. />
  307. <ArrowH
  308. class={iconClass.value}
  309. v-tippy={{
  310. content: "水平翻转",
  311. placement: "left-start"
  312. }}
  313. onClick={() => handCropper("scaleX", -1)}
  314. />
  315. <ArrowV
  316. class={iconClass.value}
  317. v-tippy={{
  318. content: "垂直翻转",
  319. placement: "right-start"
  320. }}
  321. onClick={() => handCropper("scaleY", -1)}
  322. />
  323. <RotateLeft
  324. class={iconClass.value}
  325. v-tippy={{
  326. content: "逆时针旋转",
  327. placement: "left-start"
  328. }}
  329. onClick={() => handCropper("rotate", -45)}
  330. />
  331. <RotateRight
  332. class={iconClass.value}
  333. v-tippy={{
  334. content: "顺时针旋转",
  335. placement: "right-start"
  336. }}
  337. onClick={() => handCropper("rotate", 45)}
  338. />
  339. <SearchPlus
  340. class={iconClass.value}
  341. v-tippy={{
  342. content: "放大(可长按)",
  343. placement: "left-start"
  344. }}
  345. v-longpress={[() => handCropper("zoom", 0.1), "0:100"]}
  346. />
  347. <SearchMinus
  348. class={iconClass.value}
  349. v-tippy={{
  350. content: "缩小(可长按)",
  351. placement: "right-start"
  352. }}
  353. v-longpress={[() => handCropper("zoom", -0.1), "0:100"]}
  354. />
  355. </div>
  356. );
  357. }
  358. });
  359. function onContextmenu(event) {
  360. event.preventDefault();
  361. const { show, setProps, destroy, state } = useTippy(tippyElRef, {
  362. content: menuContent,
  363. arrow: false,
  364. theme: "light",
  365. trigger: "manual",
  366. interactive: true,
  367. appendTo: "parent",
  368. // hideOnClick: false,
  369. placement: "bottom-end"
  370. });
  371. setProps({
  372. getReferenceClientRect: () => ({
  373. width: 0,
  374. height: 0,
  375. top: event.clientY,
  376. bottom: event.clientY,
  377. left: event.clientX,
  378. right: event.clientX
  379. })
  380. });
  381. show();
  382. if (isInClose.value) {
  383. if (!state.value.isShown && !state.value.isVisible) return;
  384. useEventListener(tippyElRef, "click", destroy);
  385. }
  386. }
  387. return {
  388. inSrc,
  389. props,
  390. imgElRef,
  391. tippyElRef,
  392. getClass,
  393. getWrapperStyle,
  394. getImageStyle,
  395. isReady,
  396. croppered,
  397. onContextmenu
  398. };
  399. },
  400. render() {
  401. const {
  402. inSrc,
  403. isReady,
  404. getClass,
  405. getImageStyle,
  406. onContextmenu,
  407. getWrapperStyle
  408. } = this;
  409. const { alt, crossorigin } = this.props;
  410. return inSrc ? (
  411. <div
  412. ref="tippyElRef"
  413. class={getClass}
  414. style={getWrapperStyle}
  415. onContextmenu={event => onContextmenu(event)}
  416. >
  417. <img
  418. v-show={isReady}
  419. ref="imgElRef"
  420. style={getImageStyle}
  421. src={inSrc}
  422. alt={alt}
  423. crossorigin={crossorigin}
  424. />
  425. </div>
  426. ) : null;
  427. }
  428. });