mirror of
https://github.com/pure-admin/vue-pure-admin.git
synced 2025-06-04 07:27:41 +08:00
458 lines
12 KiB
TypeScript
458 lines
12 KiB
TypeScript
import "./circled.css";
|
|
import Cropper from "cropperjs";
|
|
import { ElUpload } from "element-plus";
|
|
import type { CSSProperties } from "vue";
|
|
import { useEventListener } from "@vueuse/core";
|
|
import { longpress } from "@/directives/longpress";
|
|
import { useTippy, directive as tippy } from "vue-tippy";
|
|
import {
|
|
type PropType,
|
|
ref,
|
|
unref,
|
|
computed,
|
|
onMounted,
|
|
onUnmounted,
|
|
defineComponent
|
|
} from "vue";
|
|
import {
|
|
delay,
|
|
debounce,
|
|
isArray,
|
|
downloadByBase64,
|
|
useResizeObserver
|
|
} from "@pureadmin/utils";
|
|
import {
|
|
Reload,
|
|
Upload,
|
|
ArrowH,
|
|
ArrowV,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
ArrowLeft,
|
|
ChangeIcon,
|
|
ArrowRight,
|
|
RotateLeft,
|
|
SearchPlus,
|
|
RotateRight,
|
|
SearchMinus,
|
|
DownloadIcon
|
|
} from "./svg";
|
|
|
|
type Options = Cropper.Options;
|
|
|
|
const defaultOptions: Options = {
|
|
aspectRatio: 1,
|
|
zoomable: true,
|
|
zoomOnTouch: true,
|
|
zoomOnWheel: true,
|
|
cropBoxMovable: true,
|
|
cropBoxResizable: true,
|
|
toggleDragModeOnDblclick: true,
|
|
autoCrop: true,
|
|
background: true,
|
|
highlight: true,
|
|
center: true,
|
|
responsive: true,
|
|
restore: true,
|
|
checkCrossOrigin: true,
|
|
checkOrientation: true,
|
|
scalable: true,
|
|
modal: true,
|
|
guides: true,
|
|
movable: true,
|
|
rotatable: true
|
|
};
|
|
|
|
const props = {
|
|
src: { type: String, required: true },
|
|
alt: { type: String },
|
|
circled: { type: Boolean, default: false },
|
|
/** 是否可以通过点击裁剪区域关闭右键弹出的功能菜单,默认 `true` */
|
|
isClose: { type: Boolean, default: true },
|
|
realTimePreview: { type: Boolean, default: true },
|
|
height: { type: [String, Number], default: "360px" },
|
|
crossorigin: {
|
|
type: String as PropType<"" | "anonymous" | "use-credentials" | undefined>,
|
|
default: undefined
|
|
},
|
|
imageStyle: { type: Object as PropType<CSSProperties>, default: () => ({}) },
|
|
options: { type: Object as PropType<Options>, default: () => ({}) }
|
|
};
|
|
|
|
export default defineComponent({
|
|
name: "ReCropper",
|
|
props,
|
|
setup(props, { attrs, emit }) {
|
|
const tippyElRef = ref<ElRef<HTMLImageElement>>();
|
|
const imgElRef = ref<ElRef<HTMLImageElement>>();
|
|
const cropper = ref<Nullable<Cropper>>();
|
|
const inCircled = ref(props.circled);
|
|
const isInClose = ref(props.isClose);
|
|
const inSrc = ref(props.src);
|
|
const isReady = ref(false);
|
|
const imgBase64 = ref();
|
|
|
|
let scaleX = 1;
|
|
let scaleY = 1;
|
|
|
|
const debounceRealTimeCroppered = debounce(realTimeCroppered, 80);
|
|
|
|
const getImageStyle = computed((): CSSProperties => {
|
|
return {
|
|
height: props.height,
|
|
maxWidth: "100%",
|
|
...props.imageStyle
|
|
};
|
|
});
|
|
|
|
const getClass = computed(() => {
|
|
return [
|
|
attrs.class,
|
|
{
|
|
["re-circled"]: inCircled.value
|
|
}
|
|
];
|
|
});
|
|
|
|
const iconClass = computed(() => {
|
|
return [
|
|
"p-[6px]",
|
|
"h-[30px]",
|
|
"w-[30px]",
|
|
"outline-hidden",
|
|
"rounded-[4px]",
|
|
"cursor-pointer",
|
|
"hover:bg-[rgba(0,0,0,0.06)]"
|
|
];
|
|
});
|
|
|
|
const getWrapperStyle = computed((): CSSProperties => {
|
|
return { height: `${props.height}`.replace(/px/, "") + "px" };
|
|
});
|
|
|
|
onMounted(init);
|
|
|
|
onUnmounted(() => {
|
|
cropper.value?.destroy();
|
|
isReady.value = false;
|
|
cropper.value = null;
|
|
imgBase64.value = "";
|
|
scaleX = 1;
|
|
scaleY = 1;
|
|
});
|
|
|
|
useResizeObserver(tippyElRef, () => handCropper("reset"));
|
|
|
|
async function init() {
|
|
const imgEl = unref(imgElRef);
|
|
if (!imgEl) return;
|
|
cropper.value = new Cropper(imgEl, {
|
|
...defaultOptions,
|
|
ready: () => {
|
|
isReady.value = true;
|
|
realTimeCroppered();
|
|
delay(400).then(() => emit("readied", cropper.value));
|
|
},
|
|
crop() {
|
|
debounceRealTimeCroppered();
|
|
},
|
|
zoom() {
|
|
debounceRealTimeCroppered();
|
|
},
|
|
cropmove() {
|
|
debounceRealTimeCroppered();
|
|
},
|
|
...props.options
|
|
});
|
|
}
|
|
|
|
function realTimeCroppered() {
|
|
props.realTimePreview && croppered();
|
|
}
|
|
|
|
function croppered() {
|
|
if (!cropper.value) return;
|
|
const canvas = inCircled.value
|
|
? getRoundedCanvas()
|
|
: cropper.value.getCroppedCanvas();
|
|
// https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLCanvasElement/toBlob
|
|
canvas.toBlob(blob => {
|
|
if (!blob) return;
|
|
const fileReader: FileReader = new FileReader();
|
|
fileReader.readAsDataURL(blob);
|
|
fileReader.onloadend = e => {
|
|
if (!e.target?.result || !blob) return;
|
|
imgBase64.value = e.target.result;
|
|
emit("cropper", {
|
|
base64: e.target.result,
|
|
blob,
|
|
info: { size: blob.size, ...cropper.value.getData() }
|
|
});
|
|
};
|
|
fileReader.onerror = () => {
|
|
emit("error");
|
|
};
|
|
});
|
|
}
|
|
|
|
function getRoundedCanvas() {
|
|
const sourceCanvas = cropper.value!.getCroppedCanvas();
|
|
const canvas = document.createElement("canvas");
|
|
const context = canvas.getContext("2d")!;
|
|
const width = sourceCanvas.width;
|
|
const height = sourceCanvas.height;
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
context.imageSmoothingEnabled = true;
|
|
context.drawImage(sourceCanvas, 0, 0, width, height);
|
|
context.globalCompositeOperation = "destination-in";
|
|
context.beginPath();
|
|
context.arc(
|
|
width / 2,
|
|
height / 2,
|
|
Math.min(width, height) / 2,
|
|
0,
|
|
2 * Math.PI,
|
|
true
|
|
);
|
|
context.fill();
|
|
return canvas;
|
|
}
|
|
|
|
function handCropper(event: string, arg?: number | Array<number>) {
|
|
if (event === "scaleX") {
|
|
scaleX = arg = scaleX === -1 ? 1 : -1;
|
|
}
|
|
|
|
if (event === "scaleY") {
|
|
scaleY = arg = scaleY === -1 ? 1 : -1;
|
|
}
|
|
arg && isArray(arg)
|
|
? cropper.value?.[event]?.(...arg)
|
|
: cropper.value?.[event]?.(arg);
|
|
}
|
|
|
|
function beforeUpload(file) {
|
|
const reader = new FileReader();
|
|
reader.readAsDataURL(file);
|
|
inSrc.value = "";
|
|
reader.onload = e => {
|
|
inSrc.value = e.target?.result as string;
|
|
};
|
|
reader.onloadend = () => {
|
|
init();
|
|
};
|
|
return false;
|
|
}
|
|
|
|
const menuContent = defineComponent({
|
|
directives: {
|
|
tippy,
|
|
longpress
|
|
},
|
|
setup() {
|
|
return () => (
|
|
<div class="flex flex-wrap w-[60px] justify-between">
|
|
<ElUpload
|
|
accept="image/*"
|
|
show-file-list={false}
|
|
before-upload={beforeUpload}
|
|
>
|
|
<Upload
|
|
class={iconClass.value}
|
|
v-tippy={{
|
|
content: "上传",
|
|
placement: "left-start"
|
|
}}
|
|
/>
|
|
</ElUpload>
|
|
<DownloadIcon
|
|
class={iconClass.value}
|
|
v-tippy={{
|
|
content: "下载",
|
|
placement: "right-start"
|
|
}}
|
|
onClick={() => downloadByBase64(imgBase64.value, "cropping.png")}
|
|
/>
|
|
<ChangeIcon
|
|
class={iconClass.value}
|
|
v-tippy={{
|
|
content: "圆形、矩形裁剪",
|
|
placement: "left-start"
|
|
}}
|
|
onClick={() => {
|
|
inCircled.value = !inCircled.value;
|
|
realTimeCroppered();
|
|
}}
|
|
/>
|
|
<Reload
|
|
class={iconClass.value}
|
|
v-tippy={{
|
|
content: "重置",
|
|
placement: "right-start"
|
|
}}
|
|
onClick={() => handCropper("reset")}
|
|
/>
|
|
<ArrowUp
|
|
class={iconClass.value}
|
|
v-tippy={{
|
|
content: "上移(可长按)",
|
|
placement: "left-start"
|
|
}}
|
|
v-longpress={[() => handCropper("move", [0, -10]), "0:100"]}
|
|
/>
|
|
<ArrowDown
|
|
class={iconClass.value}
|
|
v-tippy={{
|
|
content: "下移(可长按)",
|
|
placement: "right-start"
|
|
}}
|
|
v-longpress={[() => handCropper("move", [0, 10]), "0:100"]}
|
|
/>
|
|
<ArrowLeft
|
|
class={iconClass.value}
|
|
v-tippy={{
|
|
content: "左移(可长按)",
|
|
placement: "left-start"
|
|
}}
|
|
v-longpress={[() => handCropper("move", [-10, 0]), "0:100"]}
|
|
/>
|
|
<ArrowRight
|
|
class={iconClass.value}
|
|
v-tippy={{
|
|
content: "右移(可长按)",
|
|
placement: "right-start"
|
|
}}
|
|
v-longpress={[() => handCropper("move", [10, 0]), "0:100"]}
|
|
/>
|
|
<ArrowH
|
|
class={iconClass.value}
|
|
v-tippy={{
|
|
content: "水平翻转",
|
|
placement: "left-start"
|
|
}}
|
|
onClick={() => handCropper("scaleX", -1)}
|
|
/>
|
|
<ArrowV
|
|
class={iconClass.value}
|
|
v-tippy={{
|
|
content: "垂直翻转",
|
|
placement: "right-start"
|
|
}}
|
|
onClick={() => handCropper("scaleY", -1)}
|
|
/>
|
|
<RotateLeft
|
|
class={iconClass.value}
|
|
v-tippy={{
|
|
content: "逆时针旋转",
|
|
placement: "left-start"
|
|
}}
|
|
onClick={() => handCropper("rotate", -45)}
|
|
/>
|
|
<RotateRight
|
|
class={iconClass.value}
|
|
v-tippy={{
|
|
content: "顺时针旋转",
|
|
placement: "right-start"
|
|
}}
|
|
onClick={() => handCropper("rotate", 45)}
|
|
/>
|
|
<SearchPlus
|
|
class={iconClass.value}
|
|
v-tippy={{
|
|
content: "放大(可长按)",
|
|
placement: "left-start"
|
|
}}
|
|
v-longpress={[() => handCropper("zoom", 0.1), "0:100"]}
|
|
/>
|
|
<SearchMinus
|
|
class={iconClass.value}
|
|
v-tippy={{
|
|
content: "缩小(可长按)",
|
|
placement: "right-start"
|
|
}}
|
|
v-longpress={[() => handCropper("zoom", -0.1), "0:100"]}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
function onContextmenu(event) {
|
|
event.preventDefault();
|
|
|
|
const { show, setProps, destroy, state } = useTippy(tippyElRef, {
|
|
content: menuContent,
|
|
arrow: false,
|
|
theme: "light",
|
|
trigger: "manual",
|
|
interactive: true,
|
|
appendTo: "parent",
|
|
// hideOnClick: false,
|
|
placement: "bottom-end"
|
|
});
|
|
|
|
setProps({
|
|
getReferenceClientRect: () => ({
|
|
width: 0,
|
|
height: 0,
|
|
top: event.clientY,
|
|
bottom: event.clientY,
|
|
left: event.clientX,
|
|
right: event.clientX
|
|
})
|
|
});
|
|
|
|
show();
|
|
|
|
if (isInClose.value) {
|
|
if (!state.value.isShown && !state.value.isVisible) return;
|
|
useEventListener(tippyElRef, "click", destroy);
|
|
}
|
|
}
|
|
|
|
return {
|
|
inSrc,
|
|
props,
|
|
imgElRef,
|
|
tippyElRef,
|
|
getClass,
|
|
getWrapperStyle,
|
|
getImageStyle,
|
|
isReady,
|
|
croppered,
|
|
onContextmenu
|
|
};
|
|
},
|
|
|
|
render() {
|
|
const {
|
|
inSrc,
|
|
isReady,
|
|
getClass,
|
|
getImageStyle,
|
|
onContextmenu,
|
|
getWrapperStyle
|
|
} = this;
|
|
const { alt, crossorigin } = this.props;
|
|
|
|
return inSrc ? (
|
|
<div
|
|
ref="tippyElRef"
|
|
class={getClass}
|
|
style={getWrapperStyle}
|
|
onContextmenu={event => onContextmenu(event)}
|
|
>
|
|
<img
|
|
v-show={isReady}
|
|
ref="imgElRef"
|
|
style={getImageStyle}
|
|
src={inSrc}
|
|
alt={alt}
|
|
crossorigin={crossorigin}
|
|
/>
|
|
</div>
|
|
) : null;
|
|
}
|
|
});
|