|
|
@@ -0,0 +1,793 @@
|
|
|
+<template>
|
|
|
+ <div
|
|
|
+ class="paper_container"
|
|
|
+ ref="paperContainer"
|
|
|
+ @mousedown="onMouseDown"
|
|
|
+ @mousemove="onMouseMove"
|
|
|
+ @mouseup="onMouseUp"
|
|
|
+ @wheel="onWheel"
|
|
|
+ >
|
|
|
+ <canvas
|
|
|
+ id="paperCanvas"
|
|
|
+ ref="paperCanvas"
|
|
|
+ class="paper_canvas"
|
|
|
+ @mouseleave="onCanvasLeave"
|
|
|
+ :style="{ transform: `rotate(${rotateDeg}deg)` }"
|
|
|
+ ></canvas>
|
|
|
+ <div
|
|
|
+ ref="imgContainer"
|
|
|
+ class="img_container"
|
|
|
+ :style="{ transform: `rotate(${rotateDeg}deg)` }"
|
|
|
+ >
|
|
|
+ <img v-if="paperImgUrl" :src="paperImgUrl" />
|
|
|
+ </div>
|
|
|
+ <div class="no_paper_url" v-if="paperImgUrl == ''">暂无数据</div>
|
|
|
+ <div
|
|
|
+ v-if="state.showContextMenu"
|
|
|
+ class="custom_context_menu"
|
|
|
+ :style="{ top: state.contextMenuY + 'px', left: state.contextMenuX + 'px' }"
|
|
|
+ @click="hideContextMenu"
|
|
|
+ >
|
|
|
+ <div class="menu_item" @click="handleMenuAction('download')">图片另存为</div>
|
|
|
+ <div class="menu_item" @click="handleMenuAction('fitScreen')">适合屏幕</div>
|
|
|
+ <div class="menu_item" @click="handleMenuAction('zoomIn')">放大(向上滚轮)</div>
|
|
|
+ <div class="menu_item" @click="handleMenuAction('zoomOut')">缩小(向下滚轮)</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script lang="ts" setup>
|
|
|
+import { ref, reactive, watch, onMounted, onBeforeUnmount, nextTick, getCurrentInstance } from "vue";
|
|
|
+import { throttle } from "lodash-es";
|
|
|
+import { ElLoading } from "element-plus";
|
|
|
+
|
|
|
+// ================= 资源导入 (替代 require) =================
|
|
|
+import iconAllWrong from "@/assets/icon/icon_all_wrong.svg";
|
|
|
+import iconAllRight from "@/assets/icon/icon_all_right.svg";
|
|
|
+import iconHalfRight from "@/assets/icon/icon_half_right.svg";
|
|
|
+import model2 from "@/assets/icon/model_2.png";
|
|
|
+import model1 from "@/assets/icon/model_1.png";
|
|
|
+
|
|
|
+// ================= 类型定义 =================
|
|
|
+interface PaperInfoProp {
|
|
|
+ width?: number;
|
|
|
+ height?: number;
|
|
|
+}
|
|
|
+
|
|
|
+interface DrawItem {
|
|
|
+ samplingPosition: string;
|
|
|
+ questionName: string;
|
|
|
+ score: number | string;
|
|
|
+ fullScore: number;
|
|
|
+ titleType?: number;
|
|
|
+ pagePaintingVOS?: any[];
|
|
|
+ drawLineData?: string;
|
|
|
+ [key: string]: any;
|
|
|
+}
|
|
|
+
|
|
|
+// ================= 全局实例获取 =================
|
|
|
+const { proxy } = getCurrentInstance()!;
|
|
|
+const $global = (proxy as any)?.$global;
|
|
|
+
|
|
|
+// ================= Props & Emits =================
|
|
|
+const props = withDefaults(
|
|
|
+ defineProps<{
|
|
|
+ drawData?: DrawItem[];
|
|
|
+ paperInfo?: PaperInfoProp | null;
|
|
|
+ paperImgUrl?: string;
|
|
|
+ currentId?: string;
|
|
|
+ isDrag?: boolean;
|
|
|
+ isWheel?: boolean;
|
|
|
+ isShowContextMenu?: boolean;
|
|
|
+ rotateDeg?: string;
|
|
|
+ isAbnormal?: boolean;
|
|
|
+ usedCardType?: number;
|
|
|
+ currentPage?: number;
|
|
|
+ downLoadName?: string;
|
|
|
+ }>(),
|
|
|
+ {
|
|
|
+ drawData: () => [],
|
|
|
+ paperInfo: null,
|
|
|
+ paperImgUrl: "",
|
|
|
+ currentId: "",
|
|
|
+ isDrag: true,
|
|
|
+ isWheel: true,
|
|
|
+ isShowContextMenu: true,
|
|
|
+ rotateDeg: "0",
|
|
|
+ isAbnormal: false,
|
|
|
+ usedCardType: 2,
|
|
|
+ currentPage: 1,
|
|
|
+ downLoadName: "",
|
|
|
+ }
|
|
|
+);
|
|
|
+
|
|
|
+const emit = defineEmits<{
|
|
|
+ (e: "GetRectPoint", point: any): void;
|
|
|
+}>();
|
|
|
+
|
|
|
+// ================= DOM Refs =================
|
|
|
+const paperContainer = ref<HTMLDivElement | null>(null);
|
|
|
+const paperCanvas = ref<HTMLCanvasElement | null>(null);
|
|
|
+const imgContainer = ref<HTMLDivElement | null>(null);
|
|
|
+
|
|
|
+// ================= 响应式状态管理 =================
|
|
|
+const state = reactive({
|
|
|
+ position: { x: 0, y: 0 },
|
|
|
+ startX: 0,
|
|
|
+ startY: 0,
|
|
|
+ scale: 1,
|
|
|
+ isDragging: false,
|
|
|
+ isDrawing: false,
|
|
|
+ drawType: 0,
|
|
|
+ paperImgInfo: { width: 0, height: 0 },
|
|
|
+ canvasInfo: { width: 0, height: 0 },
|
|
|
+ zoomRate: 0,
|
|
|
+ dpr: window.devicePixelRatio || 1,
|
|
|
+ minScale: 0.7,
|
|
|
+ maxScale: 4,
|
|
|
+ rectPoint: { startX: 0, startY: 0, endX: 0, endY: 0 },
|
|
|
+ addObjectAreaOption: {
|
|
|
+ derection: 1, questionType: 1, startNumber: 16, endNumber: 20,
|
|
|
+ interval: 1, answerNumber: 4, answerWidth: 0, answerHeight: 0,
|
|
|
+ },
|
|
|
+ currenPoint: { x: 0, y: 0, w: 0, h: 0 },
|
|
|
+ containerWidth: 0,
|
|
|
+ containerHeight: 0,
|
|
|
+ isInit: true,
|
|
|
+ showContextMenu: false,
|
|
|
+ contextMenuX: 0,
|
|
|
+ contextMenuY: 0,
|
|
|
+ loading: false,
|
|
|
+ downloading: false,
|
|
|
+ jHeight: 0,
|
|
|
+ blockList: [] as any[],
|
|
|
+});
|
|
|
+
|
|
|
+// ================= 非响应式内部变量 (Canvas 相关) =================
|
|
|
+let image: HTMLImageElement | null = null;
|
|
|
+let canvasCtx: CanvasRenderingContext2D | null = null;
|
|
|
+
|
|
|
+// 缓存图片
|
|
|
+const CacheAllWrong = new Image();
|
|
|
+const CacheAllRight = new Image();
|
|
|
+const CacheHalfRight = new Image();
|
|
|
+const CacheTypicalError = new Image();
|
|
|
+const CacheExcellentAnswer = new Image();
|
|
|
+
|
|
|
+CacheAllWrong.src = iconAllWrong;
|
|
|
+CacheAllRight.src = iconAllRight;
|
|
|
+CacheHalfRight.src = iconHalfRight;
|
|
|
+CacheTypicalError.src = model2;
|
|
|
+CacheExcellentAnswer.src = model1;
|
|
|
+
|
|
|
+// ================= 核心方法 =================
|
|
|
+
|
|
|
+// 工具方法:转成整数
|
|
|
+const GetInteger = (value: any): number => {
|
|
|
+ if (value === null || value === undefined || value === "") return 0;
|
|
|
+ if (typeof value === "number") return isNaN(value) ? 0 : Math.round(value);
|
|
|
+ if (typeof value === "string") {
|
|
|
+ value = value.trim();
|
|
|
+ if (value === "") return 0;
|
|
|
+ const num = Number(value);
|
|
|
+ return isNaN(num) ? 0 : Math.round(num);
|
|
|
+ }
|
|
|
+ const num = Number(value);
|
|
|
+ return isNaN(num) ? 0 : Math.round(num);
|
|
|
+};
|
|
|
+
|
|
|
+// 更新缩放率
|
|
|
+const updateZoomAndPaperInfo = () => {
|
|
|
+ let widthZoomRate = 1;
|
|
|
+ let heightZoomRate = 1;
|
|
|
+ if (props.rotateDeg === "-90") {
|
|
|
+ widthZoomRate = state.containerWidth / state.paperImgInfo.height!;
|
|
|
+ heightZoomRate = (state.containerHeight - 40) / state.paperImgInfo.width!;
|
|
|
+ } else {
|
|
|
+ widthZoomRate = state.containerWidth / state.paperImgInfo.width!;
|
|
|
+ heightZoomRate = state.containerHeight / state.paperImgInfo.height!;
|
|
|
+ }
|
|
|
+ state.zoomRate = Math.min(widthZoomRate, heightZoomRate);
|
|
|
+};
|
|
|
+
|
|
|
+// 更新画布尺寸
|
|
|
+const updateCanvasSize = () => {
|
|
|
+ state.canvasInfo.width = Math.round(state.paperImgInfo.width! * state.zoomRate * state.scale);
|
|
|
+ state.canvasInfo.height = Math.round(state.paperImgInfo.height! * state.zoomRate * state.scale);
|
|
|
+ if (paperCanvas.value) {
|
|
|
+ paperCanvas.value.width = state.canvasInfo.width;
|
|
|
+ paperCanvas.value.height = state.canvasInfo.height;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 中心化画布
|
|
|
+const centerCanvas = () => {
|
|
|
+ state.position.x = (state.containerWidth - state.canvasInfo.width) / 2;
|
|
|
+ if (props.rotateDeg === "-90") {
|
|
|
+ state.position.y = (state.containerHeight - state.canvasInfo.height) / 2 - 40;
|
|
|
+ } else {
|
|
|
+ state.position.y = (state.containerHeight - state.canvasInfo.height) / 2;
|
|
|
+ }
|
|
|
+ if (paperCanvas.value) {
|
|
|
+ paperCanvas.value.style.left = `${state.position.x}px`;
|
|
|
+ paperCanvas.value.style.top = `${state.position.y}px`;
|
|
|
+ }
|
|
|
+ state.isInit = false;
|
|
|
+};
|
|
|
+
|
|
|
+// 图片宽高坐标变化
|
|
|
+const ImageInfoChange = () => {
|
|
|
+ const { width, height } = state.paperImgInfo;
|
|
|
+ if (imgContainer.value) {
|
|
|
+ imgContainer.value.style.width = width! * state.zoomRate * state.scale + "px";
|
|
|
+ imgContainer.value.style.height = height! * state.zoomRate * state.scale + "px";
|
|
|
+ imgContainer.value.style.left = state.position.x + "px";
|
|
|
+ imgContainer.value.style.top = state.position.y + "px";
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 初始数据加载
|
|
|
+const InitData = () => {
|
|
|
+ if (!paperContainer.value) return;
|
|
|
+ const { width, height } = paperContainer.value.getBoundingClientRect();
|
|
|
+ state.containerHeight = Number(height);
|
|
|
+ state.containerWidth = Number(width);
|
|
|
+
|
|
|
+ if (props.paperInfo?.width && props.paperInfo?.height) {
|
|
|
+ state.paperImgInfo.width = props.paperInfo.width;
|
|
|
+ state.paperImgInfo.height = props.paperInfo.height;
|
|
|
+ updateZoomAndPaperInfo();
|
|
|
+ updateCanvasSize();
|
|
|
+ if (state.isInit) centerCanvas();
|
|
|
+ loadImage();
|
|
|
+ } else {
|
|
|
+ image = new Image();
|
|
|
+ image.crossOrigin = "anonymous";
|
|
|
+ image.src = props.paperImgUrl;
|
|
|
+ image.onload = () => {
|
|
|
+ state.paperImgInfo.width = image!.width;
|
|
|
+ state.paperImgInfo.height = image!.height;
|
|
|
+ updateZoomAndPaperInfo();
|
|
|
+ updateCanvasSize();
|
|
|
+ centerCanvas();
|
|
|
+ loadImage();
|
|
|
+ };
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 加载图片
|
|
|
+const loadImage = () => {
|
|
|
+ setTimeout(() => {
|
|
|
+ state.loading = false;
|
|
|
+ }, 100);
|
|
|
+ drawImage();
|
|
|
+};
|
|
|
+
|
|
|
+// 绘制图片及批注
|
|
|
+const drawImage = () => {
|
|
|
+ if (!paperCanvas.value) return;
|
|
|
+ canvasCtx = paperCanvas.value.getContext("2d");
|
|
|
+ if (!canvasCtx) return;
|
|
|
+
|
|
|
+ canvasCtx.clearRect(0, 0, state.canvasInfo.width, state.canvasInfo.height);
|
|
|
+ ImageInfoChange();
|
|
|
+ DrawDataInfo(canvasCtx, state.zoomRate, state.scale);
|
|
|
+};
|
|
|
+
|
|
|
+// 绘制批注信息 (核心绘图逻辑)
|
|
|
+const DrawDataInfo = (ctx: CanvasRenderingContext2D, zoomRate: number, scale: number) => {
|
|
|
+ if (!props.drawData) return;
|
|
|
+ for (let i = 0; i < props.drawData.length; i++) {
|
|
|
+ let item = props.drawData[i];
|
|
|
+ const point = JSON.parse(item.samplingPosition);
|
|
|
+ let blockPoint: any = "";
|
|
|
+ let blockList: any[] = [];
|
|
|
+ let jHeight = 0;
|
|
|
+ let obj = { x: point.x * zoomRate * scale, y: point.y * zoomRate * scale };
|
|
|
+
|
|
|
+ if (props.usedCardType == 1) {
|
|
|
+ let templateInfo = { width: 794 - 30 * 2, height: 1123 - 25 * 2 };
|
|
|
+ if (state.paperImgInfo.width! > state.paperImgInfo.height!) {
|
|
|
+ templateInfo = { width: 1588 - 30 * 2, height: 1123 - 25 * 2 };
|
|
|
+ }
|
|
|
+ let imagePoint: any = {};
|
|
|
+ if (item.titleType == 1) {
|
|
|
+ imagePoint = {
|
|
|
+ x: ((point.x - 30) / templateInfo.width) * state.paperImgInfo.width!,
|
|
|
+ y: ((point.y - 15) / templateInfo.height) * state.paperImgInfo.height!,
|
|
|
+ };
|
|
|
+ } else {
|
|
|
+ imagePoint = {
|
|
|
+ x: (point.x / templateInfo.width) * state.paperImgInfo.width!,
|
|
|
+ y: (point.y / templateInfo.height) * state.paperImgInfo.height!,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ obj = { x: imagePoint.x * zoomRate * scale, y: imagePoint.y * zoomRate * scale };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (item.pagePaintingVOS) {
|
|
|
+ const pointIndex = point.index || 0;
|
|
|
+ blockPoint = item.pagePaintingVOS[pointIndex];
|
|
|
+ const pointPage = point.page;
|
|
|
+ if (item.pagePaintingVOS.length > 1) blockList = item.pagePaintingVOS || [];
|
|
|
+
|
|
|
+ let newBlockPoint = blockPoint;
|
|
|
+ if (props.usedCardType == 1) {
|
|
|
+ let templateInfo = { width: 794 - 30 * 2, height: 1123 - 25 * 2 };
|
|
|
+ if (state.paperImgInfo.width! > state.paperImgInfo.height!) {
|
|
|
+ templateInfo = { width: 1588 - 30 * 2, height: 1123 - 25 * 2 };
|
|
|
+ }
|
|
|
+ newBlockPoint = {
|
|
|
+ x: GetInteger(((blockPoint.x - 30) / templateInfo.width) * state.paperImgInfo.width!),
|
|
|
+ y: GetInteger(((blockPoint.y - 25) / templateInfo.height) * state.paperImgInfo.height!),
|
|
|
+ w: GetInteger((blockPoint.w / templateInfo.width) * state.paperImgInfo.width!),
|
|
|
+ h: GetInteger((blockPoint.h / templateInfo.height) * state.paperImgInfo.height!),
|
|
|
+ page: blockPoint.page,
|
|
|
+ };
|
|
|
+ blockPoint = newBlockPoint;
|
|
|
+ if (item.pagePaintingVOS.length > 1) {
|
|
|
+ blockList = [];
|
|
|
+ for (let j = 0; j < item.pagePaintingVOS.length; j++) {
|
|
|
+ let blockObj = item.pagePaintingVOS[j];
|
|
|
+ blockList.push({
|
|
|
+ x: GetInteger(((blockObj.x - 30) / templateInfo.width) * state.paperImgInfo.width!),
|
|
|
+ y: GetInteger(((blockObj.y - 25) / templateInfo.height) * state.paperImgInfo.height!),
|
|
|
+ w: GetInteger((blockObj.w / templateInfo.width) * state.paperImgInfo.width!),
|
|
|
+ h: GetInteger((blockObj.h / templateInfo.height) * state.paperImgInfo.height!),
|
|
|
+ page: blockObj.page,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (pointPage != props.currentPage) jHeight = newBlockPoint.h;
|
|
|
+ if (blockPoint) {
|
|
|
+ obj.x = GetInteger(newBlockPoint.x * zoomRate * scale + obj.x);
|
|
|
+ obj.y = GetInteger(newBlockPoint.y * zoomRate * scale + obj.y);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ ctx.fillStyle = "#D81E06";
|
|
|
+ ctx.textAlign = "center";
|
|
|
+ ctx.textBaseline = "middle";
|
|
|
+
|
|
|
+ if (item.questionName == "总分") {
|
|
|
+ const fontSize = Math.max(12, 50 * scale);
|
|
|
+ ctx.font = `${fontSize}px Arial`;
|
|
|
+ ctx.textAlign = "left";
|
|
|
+ const text = item.score.toString();
|
|
|
+ const textWidth = ctx.measureText(text).width;
|
|
|
+ const underlineY = obj.y + 50 * zoomRate * scale;
|
|
|
+ const startX = obj.x + 35 * zoomRate * scale;
|
|
|
+ ctx.beginPath(); ctx.moveTo(startX, underlineY); ctx.lineTo(startX + textWidth, underlineY);
|
|
|
+ ctx.strokeStyle = ctx.fillStyle; ctx.lineWidth = 4; ctx.stroke();
|
|
|
+ ctx.beginPath(); ctx.moveTo(startX - 20, underlineY + 20 * zoomRate * scale);
|
|
|
+ ctx.lineTo(startX + textWidth + 20, underlineY + 20 * zoomRate * scale);
|
|
|
+ ctx.stroke();
|
|
|
+ } else {
|
|
|
+ const fontSize = Math.max(12, 18 * scale);
|
|
|
+ ctx.font = `${fontSize}px Arial`;
|
|
|
+ ctx.textAlign = "left";
|
|
|
+ const iconX = obj.x - 20 * zoomRate * scale;
|
|
|
+ const iconY = obj.y - 20 * zoomRate * scale;
|
|
|
+ const iconWidth = 40 * zoomRate * scale;
|
|
|
+ const iconHeight = 40 * zoomRate * scale;
|
|
|
+
|
|
|
+ if (item.score == 0) ctx.drawImage(CacheAllWrong, iconX, iconY, iconWidth, iconHeight);
|
|
|
+ else if (item.score == item.fullScore) ctx.drawImage(CacheAllRight, iconX, iconY, iconWidth, iconHeight);
|
|
|
+ else ctx.drawImage(CacheHalfRight, iconX, iconY, iconWidth, iconHeight);
|
|
|
+ }
|
|
|
+ ctx.fillText(item.score, obj.x + 35 * zoomRate * scale, obj.y);
|
|
|
+
|
|
|
+ if (item.drawLineData) {
|
|
|
+ const drawLineData = JSON.parse(item.drawLineData);
|
|
|
+ if (blockList.length > 0) {
|
|
|
+ let jh = 0;
|
|
|
+ blockList.forEach((blockItem: any, index: number) => {
|
|
|
+ if (blockItem.page == props.currentPage) {
|
|
|
+ drawLineData.forEach((drawlineItem: any) => {
|
|
|
+ let findIndex = FindBlockIndex(drawlineItem, blockList);
|
|
|
+ let drawItem = drawlineItem;
|
|
|
+ if (findIndex == index) {
|
|
|
+ blockPoint = blockList[findIndex];
|
|
|
+ const zoomScale = zoomRate * scale;
|
|
|
+ const offsetX = blockPoint.x * zoomScale;
|
|
|
+ const offsetY = blockPoint.y * zoomScale;
|
|
|
+ drawSwitch(drawItem, zoomScale, offsetX, offsetY, jh, ctx);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ jh = jh + blockItem.h;
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ for (const drawItem of drawLineData) {
|
|
|
+ const zoomScale = zoomRate * scale;
|
|
|
+ const offsetX = blockPoint.x * zoomScale;
|
|
|
+ const offsetY = blockPoint.y * zoomScale;
|
|
|
+ drawSwitch(drawItem, zoomScale, offsetX, offsetY, 0, ctx);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 提取的绘图 Switch 逻辑
|
|
|
+const drawSwitch = (drawItem: any, zoomScale: number, offsetX: number, offsetY: number, jh: number, ctx: CanvasRenderingContext2D) => {
|
|
|
+ switch (drawItem.drawType) {
|
|
|
+ case 1: {
|
|
|
+ const fontSize = Math.max(12, 40 * zoomScale);
|
|
|
+ ctx.font = `${fontSize}px Arial`;
|
|
|
+ ctx.fillStyle = "#D81E06";
|
|
|
+ ctx.textAlign = "center";
|
|
|
+ ctx.textBaseline = "middle";
|
|
|
+ const x = drawItem.x * zoomScale + offsetX;
|
|
|
+ const y = drawItem.y * zoomScale + offsetY;
|
|
|
+ if (drawItem.type === "reduce") ctx.fillText("-" + drawItem.score, x, y);
|
|
|
+ else if (drawItem.type === "bonus") ctx.fillText("+" + drawItem.score, x, y);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case 2: {
|
|
|
+ const coords = {
|
|
|
+ x: GetInteger(drawItem.x * zoomScale + offsetX),
|
|
|
+ y: GetInteger((drawItem.y - jh) * zoomScale + offsetY),
|
|
|
+ endX: GetInteger(drawItem.endX * zoomScale + offsetX),
|
|
|
+ endY: GetInteger((drawItem.endY - jh) * zoomScale + offsetY),
|
|
|
+ };
|
|
|
+ DrawHorizontalLine(coords.x, coords.y, coords.endX, coords.endY, ctx);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case 3: {
|
|
|
+ const coords = {
|
|
|
+ startX: parseFloat((drawItem.x * zoomScale + offsetX).toFixed(2)),
|
|
|
+ startY: parseFloat(((drawItem.y - jh) * zoomScale + offsetY).toFixed(2)),
|
|
|
+ endX: parseFloat((drawItem.endX * zoomScale + offsetX).toFixed(2)),
|
|
|
+ endY: parseFloat(((drawItem.endY - jh) * zoomScale + offsetY).toFixed(2)),
|
|
|
+ };
|
|
|
+ DrawWaveLine(coords.startX, coords.startY, coords.endX, coords.endY, ctx);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case 4: DrawPenLine(drawItem, offsetX, offsetY, zoomScale, ctx); break;
|
|
|
+ case 5: {
|
|
|
+ const coords = { x: drawItem.x * zoomScale + offsetX, y: drawItem.y * zoomScale + offsetY };
|
|
|
+ DrawText(coords.x, coords.y, drawItem.text, ctx);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case 6: {
|
|
|
+ const coords = { x: drawItem.x * zoomScale + offsetX, y: drawItem.y * zoomScale + offsetY };
|
|
|
+ DrawText(coords.x, coords.y, "✔", ctx);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case 7: {
|
|
|
+ const coords = { x: drawItem.x * zoomScale + offsetX, y: drawItem.y * zoomScale + offsetY };
|
|
|
+ DrawText(coords.x, coords.y, "✘", ctx);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 绘图辅助方法
|
|
|
+const FindBlockIndex = (targetObj: any, arr: any[]) => {
|
|
|
+ let cumulativeHeight = 0;
|
|
|
+ for (let i = 0; i < arr.length; i++) {
|
|
|
+ const item = arr[i];
|
|
|
+ if (targetObj.drawType == 4) {
|
|
|
+ if (targetObj.drawlineData[0].y >= cumulativeHeight && targetObj.drawlineData[0].y < cumulativeHeight + item.h) return i;
|
|
|
+ } else {
|
|
|
+ if (targetObj.y >= cumulativeHeight && targetObj.y < cumulativeHeight + item.h) return i;
|
|
|
+ }
|
|
|
+ cumulativeHeight += item.h;
|
|
|
+ }
|
|
|
+ return -1;
|
|
|
+};
|
|
|
+
|
|
|
+const DrawHorizontalLine = (x1: number, y1: number, x2: number, y2: number, ctx: CanvasRenderingContext2D | null = null) => {
|
|
|
+ const c = ctx || (paperCanvas.value?.getContext("2d") as CanvasRenderingContext2D);
|
|
|
+ c.strokeStyle = "red"; c.lineWidth = 2; c.beginPath(); c.moveTo(x1, y1); c.lineTo(x2, y2); c.stroke();
|
|
|
+};
|
|
|
+
|
|
|
+const DrawWaveLine = (startX: number, startY: number, endX: number, endY: number, ctx: CanvasRenderingContext2D | null = null) => {
|
|
|
+ const amplitude = 2, frequency = 0.8;
|
|
|
+ const dx = endX - startX, dy = endY - startY;
|
|
|
+ const c = ctx || (paperCanvas.value?.getContext("2d") as CanvasRenderingContext2D);
|
|
|
+ c.strokeStyle = "red"; c.lineWidth = 2; c.beginPath(); c.moveTo(startX, startY);
|
|
|
+ for (let x = startX; x <= endX; x += 1) {
|
|
|
+ const y = startY + (dy * (x - startX)) / dx + amplitude * Math.sin(frequency * (x - startX));
|
|
|
+ c.lineTo(x, y);
|
|
|
+ }
|
|
|
+ c.stroke();
|
|
|
+};
|
|
|
+
|
|
|
+const DrawPenLine = (data: any, offsetX: number, offsetY: number, zoomScale: number, ctx: CanvasRenderingContext2D | null = null) => {
|
|
|
+ const linelist = data.drawlineData;
|
|
|
+ if (linelist.length > 0) {
|
|
|
+ const c = ctx || (paperCanvas.value?.getContext("2d") as CanvasRenderingContext2D);
|
|
|
+ c.strokeStyle = "red"; c.lineWidth = 2; c.beginPath();
|
|
|
+ for (let i = 0; i < linelist.length; i++) {
|
|
|
+ let x = parseFloat((linelist[i].x * zoomScale + offsetX).toFixed(2));
|
|
|
+ let y = parseFloat((linelist[i].y * zoomScale + offsetY).toFixed(2));
|
|
|
+ c.lineTo(x, y);
|
|
|
+ }
|
|
|
+ c.stroke(); c.closePath();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const DrawText = (startX: number, startY: number, text: string, ctx: CanvasRenderingContext2D | null = null) => {
|
|
|
+ const c = ctx || (paperCanvas.value?.getContext("2d") as CanvasRenderingContext2D);
|
|
|
+ c.font = "15px Arial"; c.fillStyle = "red"; c.textAlign = "left";
|
|
|
+ c.fillText(text, startX, startY);
|
|
|
+};
|
|
|
+
|
|
|
+// 事件处理
|
|
|
+const handleRightClick = (event: MouseEvent) => {
|
|
|
+ if (!props.isShowContextMenu || !paperContainer.value) return;
|
|
|
+ event.preventDefault(); event.stopPropagation();
|
|
|
+ const containerRect = paperContainer.value.getBoundingClientRect();
|
|
|
+ state.contextMenuX = event.clientX - containerRect.left;
|
|
|
+ state.contextMenuY = event.clientY - containerRect.top;
|
|
|
+ state.showContextMenu = true;
|
|
|
+};
|
|
|
+
|
|
|
+const handleGlobalEvent = (event: MouseEvent) => {
|
|
|
+ const menuElement = document.querySelector(".custom_context_menu");
|
|
|
+ if (state.showContextMenu && menuElement && !menuElement.contains(event.target as Node)) {
|
|
|
+ hideContextMenu();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const hideContextMenu = () => { state.showContextMenu = false; };
|
|
|
+
|
|
|
+const handleMenuAction = (action: string) => {
|
|
|
+ switch (action) {
|
|
|
+ case "zoomIn":
|
|
|
+ state.scale = Math.min(state.maxScale, state.scale + 0.1);
|
|
|
+ ImageInfoChange(); updateCanvasSize(); drawImage(); break;
|
|
|
+ case "zoomOut":
|
|
|
+ state.scale = Math.max(state.minScale, state.scale - 0.1);
|
|
|
+ ImageInfoChange(); updateCanvasSize(); drawImage(); break;
|
|
|
+ case "fitScreen": fitScreen(); break;
|
|
|
+ case "download": downloadImage(); break;
|
|
|
+ }
|
|
|
+ hideContextMenu();
|
|
|
+};
|
|
|
+
|
|
|
+const downloadImage = () => {
|
|
|
+ const loadingInstance = ElLoading.service({
|
|
|
+ lock: true, text: "正在下载中,请稍后...", background: "rgba(0, 0, 0, 0.7)",
|
|
|
+ });
|
|
|
+
|
|
|
+ const exportCanvas = document.createElement("canvas");
|
|
|
+ const exportCtx = exportCanvas.getContext("2d")!;
|
|
|
+ exportCanvas.width = state.paperImgInfo.width!;
|
|
|
+ exportCanvas.height = state.paperImgInfo.height!;
|
|
|
+
|
|
|
+ if (image) exportCtx.drawImage(image, 0, 0, state.paperImgInfo.width!, state.paperImgInfo.height!);
|
|
|
+ DrawDataInfo(exportCtx, 1, 1);
|
|
|
+
|
|
|
+ const imgUrl = exportCanvas.toDataURL("image/png");
|
|
|
+ let link = document.createElement("a");
|
|
|
+ link.href = imgUrl;
|
|
|
+ link.download = props.downLoadName + ".png";
|
|
|
+ document.body.appendChild(link); link.click(); document.body.removeChild(link);
|
|
|
+
|
|
|
+ nextTick(() => {
|
|
|
+ setTimeout(() => loadingInstance.close(), 2000);
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const fitScreen = () => {
|
|
|
+ state.scale = 1; state.isInit = true; InitData();
|
|
|
+};
|
|
|
+
|
|
|
+const onGlobalMouseUp = () => { state.isDragging = false; };
|
|
|
+
|
|
|
+const onMouseDown = (event: MouseEvent) => {
|
|
|
+ if (event.button !== 0) { state.isDragging = false; return; }
|
|
|
+ state.isDragging = false;
|
|
|
+ if (state.drawType == 0 && props.isDrag) {
|
|
|
+ state.isDragging = true;
|
|
|
+ state.startX = event.clientX - state.position.x;
|
|
|
+ state.startY = event.clientY - state.position.y;
|
|
|
+ }
|
|
|
+ if (state.drawType == 1 && (event.target as HTMLElement).id != "paperCanvas") {
|
|
|
+ state.isDragging = true;
|
|
|
+ state.startX = event.clientX - state.position.x;
|
|
|
+ state.startY = event.clientY - state.position.y;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const onMouseMove = (event: MouseEvent) => {
|
|
|
+ if (state.isDragging) {
|
|
|
+ state.position.x = event.clientX - state.startX;
|
|
|
+ state.position.y = event.clientY - state.startY;
|
|
|
+ if (paperCanvas.value) {
|
|
|
+ paperCanvas.value.style.left = `${state.position.x}px`;
|
|
|
+ paperCanvas.value.style.top = `${state.position.y}px`;
|
|
|
+ }
|
|
|
+ ImageInfoChange();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const onMouseUp = (event: MouseEvent) => {
|
|
|
+ if (event && event.button !== 0) return;
|
|
|
+ state.isDragging = false;
|
|
|
+};
|
|
|
+
|
|
|
+const onWheel = (event: WheelEvent) => {
|
|
|
+ if (!props.isWheel || !paperContainer.value) return;
|
|
|
+ event.preventDefault();
|
|
|
+ const delta = event.deltaY < 0 ? 1 : -1;
|
|
|
+ const newScale = state.scale + delta * 0.1;
|
|
|
+ const clampedScale = Math.max(state.minScale, Math.min(state.maxScale, newScale));
|
|
|
+ if (clampedScale === state.scale) return;
|
|
|
+
|
|
|
+ const containerRect = paperContainer.value.getBoundingClientRect();
|
|
|
+ const mouseX = event.clientX - containerRect.left;
|
|
|
+ const mouseY = event.clientY - containerRect.top;
|
|
|
+ const mouseRelativeToCanvasX = (mouseX - state.position.x) / state.scale;
|
|
|
+ const mouseRelativeToCanvasY = (mouseY - state.position.y) / state.scale;
|
|
|
+
|
|
|
+ state.scale = clampedScale;
|
|
|
+ updateCanvasSize();
|
|
|
+ state.position.x = mouseX - mouseRelativeToCanvasX * state.scale;
|
|
|
+ state.position.y = mouseY - mouseRelativeToCanvasY * state.scale;
|
|
|
+
|
|
|
+ if (paperCanvas.value) {
|
|
|
+ paperCanvas.value.style.left = `${state.position.x}px`;
|
|
|
+ paperCanvas.value.style.top = `${state.position.y}px`;
|
|
|
+ }
|
|
|
+ ImageInfoChange(); drawImage();
|
|
|
+};
|
|
|
+
|
|
|
+// 框选相关
|
|
|
+const onCanvasDown = (e: MouseEvent) => {
|
|
|
+ if (!paperCanvas.value) return;
|
|
|
+ paperCanvas.value.style.cursor = "crosshair";
|
|
|
+ state.isDrawing = true; state.isDragging = false;
|
|
|
+ state.rectPoint = { startX: e.offsetX, startY: e.offsetY, endX: e.offsetX, endY: e.offsetY };
|
|
|
+};
|
|
|
+
|
|
|
+const onCanvasMove = (e: MouseEvent) => {
|
|
|
+ if (!state.isDrawing || !paperCanvas.value) return;
|
|
|
+ const ctx = paperCanvas.value.getContext("2d")!;
|
|
|
+ state.rectPoint.endX = e.offsetX; state.rectPoint.endY = e.offsetY;
|
|
|
+ ctx.clearRect(0, 0, paperCanvas.value.width, paperCanvas.value.height);
|
|
|
+ drawImage();
|
|
|
+ ctx.strokeStyle = "blue"; ctx.lineWidth = 1;
|
|
|
+ const width = e.offsetX - state.rectPoint.startX;
|
|
|
+ const height = e.offsetY - state.rectPoint.startY;
|
|
|
+ ctx.strokeRect(state.rectPoint.startX, state.rectPoint.startY, width, height);
|
|
|
+};
|
|
|
+
|
|
|
+const onCanvasUp = () => {
|
|
|
+ state.isDrawing = false;
|
|
|
+ let { startX, startY, endX, endY } = state.rectPoint;
|
|
|
+ let actualStartX = Math.min(startX, endX), actualStartY = Math.min(startY, endY);
|
|
|
+ let width = Math.abs(endX - startX), height = Math.abs(endY - startY);
|
|
|
+
|
|
|
+ let point = {
|
|
|
+ x: $global?.floatNum(actualStartX / state.zoomRate / state.scale),
|
|
|
+ y: $global?.floatNum(actualStartY / state.zoomRate / state.scale),
|
|
|
+ w: $global?.floatNum(width / state.zoomRate / state.scale),
|
|
|
+ h: $global?.floatNum(height / state.zoomRate / state.scale),
|
|
|
+ unit: "px",
|
|
|
+ };
|
|
|
+ state.currenPoint = point;
|
|
|
+ if (point.w > 0 && point.h > 0) emit("GetRectPoint", point);
|
|
|
+};
|
|
|
+
|
|
|
+const onCanvasLeave = () => { if (state.isDrawing) onCanvasUp(); };
|
|
|
+
|
|
|
+const handleResize = throttle(() => {
|
|
|
+ // 窗口变化逻辑(原代码被注释,保留结构)
|
|
|
+}, 500);
|
|
|
+
|
|
|
+// ================= 暴露方法供父组件调用 =================
|
|
|
+const MouseReset = () => {
|
|
|
+ state.drawType = 0;
|
|
|
+ if (paperCanvas.value) {
|
|
|
+ paperCanvas.value.style.cursor = "pointer";
|
|
|
+ paperCanvas.value.onmousedown = null;
|
|
|
+ paperCanvas.value.onmousemove = null;
|
|
|
+ paperCanvas.value.onmouseup = null;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const SelectionBox = () => {
|
|
|
+ state.drawType = 1;
|
|
|
+ if (paperCanvas.value) {
|
|
|
+ paperCanvas.value.style.cursor = "crosshair";
|
|
|
+ paperCanvas.value.onmousedown = onCanvasDown as any;
|
|
|
+ paperCanvas.value.onmousemove = onCanvasMove as any;
|
|
|
+ paperCanvas.value.onmouseup = onCanvasUp as any;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const StartPaintingCircle = (obj: any) => {
|
|
|
+ state.addObjectAreaOption = obj;
|
|
|
+ SelectionBox();
|
|
|
+};
|
|
|
+
|
|
|
+defineExpose({ MouseReset, SelectionBox, StartPaintingCircle });
|
|
|
+
|
|
|
+// ================= 监听器 =================
|
|
|
+watch(
|
|
|
+ () => props.paperImgUrl,
|
|
|
+ (newVal) => {
|
|
|
+ if (newVal) { state.loading = true; InitData(); }
|
|
|
+ else { state.loading = false; }
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+);
|
|
|
+
|
|
|
+watch(() => props.drawData, () => { drawImage(); }, { deep: true });
|
|
|
+watch(() => props.currentId, () => { drawImage(); });
|
|
|
+
|
|
|
+// ================= 生命周期 =================
|
|
|
+onMounted(() => {
|
|
|
+ InitData();
|
|
|
+ nextTick(() => {
|
|
|
+ if (paperContainer.value) {
|
|
|
+ paperContainer.value.addEventListener("contextmenu", handleRightClick);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ window.addEventListener("resize", handleResize);
|
|
|
+ window.addEventListener("mouseup", onGlobalMouseUp);
|
|
|
+ document.addEventListener("click", handleGlobalEvent);
|
|
|
+ document.addEventListener("contextmenu", handleGlobalEvent);
|
|
|
+});
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ window.removeEventListener("resize", handleResize);
|
|
|
+ window.removeEventListener("mouseup", onGlobalMouseUp);
|
|
|
+ document.removeEventListener("click", handleGlobalEvent);
|
|
|
+ document.removeEventListener("contextmenu", handleGlobalEvent);
|
|
|
+ if (paperContainer.value) {
|
|
|
+ paperContainer.value.removeEventListener("contextmenu", handleRightClick);
|
|
|
+ }
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+/* 样式部分保持原样,无需修改 */
|
|
|
+.paper_container {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ overflow: hidden;
|
|
|
+ position: relative;
|
|
|
+ .paper_canvas {
|
|
|
+ position: absolute;
|
|
|
+ cursor: pointer;
|
|
|
+ background-color: transparent;
|
|
|
+ z-index: 10;
|
|
|
+ }
|
|
|
+ .img_container {
|
|
|
+ position: absolute;
|
|
|
+ cursor: pointer;
|
|
|
+ z-index: 9;
|
|
|
+ img { width: 100%; height: 100%; }
|
|
|
+ }
|
|
|
+ .custom_context_menu {
|
|
|
+ position: absolute;
|
|
|
+ background: white;
|
|
|
+ border: 1px solid #ccc;
|
|
|
+ border-radius: 4px;
|
|
|
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
|
|
+ z-index: 9999;
|
|
|
+ min-width: 120px;
|
|
|
+ .menu_item {
|
|
|
+ padding: 8px 16px;
|
|
|
+ cursor: pointer;
|
|
|
+ font-size: 14px;
|
|
|
+ &:hover { background-color: #f5f5f5; }
|
|
|
+ &:not(:last-child) { border-bottom: 1px solid #eee; }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .no_paper_url {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|