ActionImages.vue 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026
  1. <template>
  2. <div
  3. class="main_container"
  4. ref="mainContainerRef"
  5. v-loading="loading"
  6. element-loading-text="图片加载中……"
  7. element-loading-spinner="el-icon-loading"
  8. element-loading-background="#ffffff"
  9. @mousedown="onMouseDown"
  10. @mousemove="onMouseMove"
  11. @mouseup="onMouseUp"
  12. @wheel="onWheel"
  13. >
  14. <canvas
  15. id="paperCanvas"
  16. ref="paperCanvasRef"
  17. class="paper_canvas"
  18. @mouseleave="onCanvasLeave"
  19. ></canvas>
  20. <div id="imgContainer" class="img_container">
  21. <img v-if="paperImgUrl" :src="paperImgUrl" />
  22. </div>
  23. <div class="no_paper_url" v-if="paperImgUrl == ''">
  24. 暂无数据
  25. <!-- 请先制作模版 -->
  26. </div>
  27. </div>
  28. </template>
  29. <script setup>
  30. import { ref, reactive, watch, onBeforeUnmount, onMounted } from 'vue'
  31. import { throttle } from 'lodash'
  32. // import { mmToPx } from '@/utils/common.ts' // 如果需要取消注释
  33. // 定义 Props
  34. const props = defineProps({
  35. drawData: {
  36. type: Array,
  37. default: () => [],
  38. }, // 画的边框的数据
  39. paperInfo: {
  40. type: Object,
  41. default: () => null,
  42. }, // 纸张大小
  43. paperImgUrl: {
  44. type: String,
  45. default: '',
  46. }, // 试卷地址相关信息
  47. currentId: {
  48. type: String,
  49. default: '',
  50. }, // 当前选中的id
  51. isDrag: {
  52. type: Boolean,
  53. default: true,
  54. }, // 是否可以拖动 默认可以拖动
  55. isAbnormal: {
  56. type: Boolean,
  57. default: false,
  58. }, // 是否异常处使用 区别 异常处理使用的地方 正常答案显示蓝色
  59. })
  60. // 定义 Emits
  61. const emit = defineEmits(['GetRectPoint'])
  62. // 响应式数据
  63. const mainContainerRef = ref(null)
  64. const paperCanvasRef = ref(null)
  65. const position = reactive({
  66. x: 0,
  67. y: 0,
  68. }) // 初始canvas图片位置
  69. const startX = ref(0) // 鼠标按下时的初始位置x坐标
  70. const startY = ref(0) // 鼠标按下时的初始位置y坐标
  71. const scale = ref(1) // 画布缩放倍数
  72. const isDragging = ref(false) // 是否拖动画布
  73. const isDrawing = ref(false) // 是否正在画线
  74. const drawType = ref(0) // 1画线 0 拖拽模式
  75. const image = ref(null)
  76. const paperImgInfo = reactive({
  77. width: 0,
  78. height: 0,
  79. })
  80. const canvasInfo = reactive({
  81. width: 0,
  82. height: 0,
  83. }) // 画布的大小
  84. const zoomRate = ref(0) // 图片的缩放比例
  85. const dpr = window.devicePixelRatio || 1
  86. const minScale = 0.7 // 最小缩放值
  87. const maxScale = 4 // 最大缩放值
  88. const rectPoint = reactive({
  89. startX: 0,
  90. startY: 0,
  91. endX: 0,
  92. endY: 0,
  93. }) // 矩形起始坐标点
  94. const showObjectArea = ref(false) // 是否显示对象区域
  95. const addObjectAreaOption = reactive({
  96. derection: 1, // 排列方向 1 横线 2竖向
  97. questionType: 1, // 题类型 1单选 2多选 3 判断
  98. startNumber: 16, // 起始题号
  99. endNumber: 20, // 结束题号
  100. interval: 1, // 题号间隔 默认1
  101. answerNumber: 4, // 选项个数 答案个数
  102. answerWidth: 0, // 选项宽度
  103. answerHeight: 0, // 选项高度
  104. }) // 添加选择题设置信息
  105. const currenPoint = reactive({
  106. x: 0,
  107. y: 0,
  108. w: 0,
  109. h: 0,
  110. })
  111. const containerWidth = ref(0) // 容器宽度
  112. const containerHeight = ref(0) // 容器高度
  113. const isInit = ref(true) // 是否是初始加载
  114. const isShowDraw = ref(true) // 是否显示画框
  115. const loading = ref(false) // 是否正在加载图片
  116. // 模拟 this.$global.floatNum,请根据实际项目替换为真实工具函数
  117. const floatNum = (num, fixed = 2) => {
  118. if (typeof num !== 'number') return 0
  119. return Number(num.toFixed(fixed))
  120. }
  121. // 方法定义
  122. // 显示画框
  123. const ShowDrawData = () => {
  124. isShowDraw.value = !isShowDraw.value
  125. drawImage()
  126. }
  127. // 初始数据加载
  128. const initData = () => {
  129. if (!mainContainerRef.value) return
  130. console.log('初始化数据加载图片地址信息',props.paperImgUrl)
  131. // 获取容器的宽高
  132. const { width, height } = mainContainerRef.value.getBoundingClientRect()
  133. containerHeight.value = Number(height)
  134. containerWidth.value = Number(width)
  135. console.log('初始化数据加载容器宽高',containerWidth.value,containerHeight.value)
  136. image.value = new Image()
  137. image.value.src = props.paperImgUrl
  138. // 初始化获取试卷数据
  139. if (props.paperInfo?.width && props.paperInfo?.height) {
  140. // 设置初始数据
  141. paperImgInfo.width = props.paperInfo.width // 获取宽
  142. paperImgInfo.height = props.paperInfo.height // 获取高
  143. image.value.onload = () => {
  144. loading.value = false // 图片加载完成
  145. // 更新缩放率
  146. updateZoomAndPaperInfo()
  147. // 更新画布尺寸
  148. updateCanvasSize()
  149. // 计算中心位置使图片居中 初始加载图片是居中
  150. if (isInit.value) {
  151. centerCanvas()
  152. }
  153. // 加载图片
  154. loadImage()
  155. }
  156. } else {
  157. // 没有值 默认图片的宽高
  158. image.value.onload = () => {
  159. paperImgInfo.width = image.value.width // 获取宽
  160. paperImgInfo.height = image.value.height // 获取高
  161. loading.value = false // 图片加载完成
  162. // 更新缩放率
  163. updateZoomAndPaperInfo()
  164. // 更新画布尺寸
  165. updateCanvasSize()
  166. // 计算中心位置使图片居中
  167. if (isInit.value) {
  168. centerCanvas()
  169. }
  170. // 加载图片
  171. loadImage()
  172. }
  173. }
  174. }
  175. // 切换试卷图片
  176. const chanagePaperImage = () => {
  177. // 更新缩放率
  178. updateZoomAndPaperInfo()
  179. // 更新画布尺寸
  180. updateCanvasSize()
  181. // 加载图片
  182. loadImage()
  183. }
  184. // 适合屏幕
  185. const fitScreen = () => {
  186. scale.value = 1
  187. isInit.value = true
  188. initData() // 初始化屏幕加载
  189. }
  190. // 加载图片
  191. const loadImage = () => {
  192. drawImage() // 绘制边框数据
  193. }
  194. // 图片宽高坐标变化
  195. const ImageInfoChange = () => {
  196. const { width, height } = paperImgInfo
  197. const imgDom = document.getElementById('imgContainer')
  198. if (imgDom) {
  199. imgDom.style.width = width * zoomRate.value * scale.value + 'px'
  200. imgDom.style.height = height * zoomRate.value * scale.value + 'px'
  201. imgDom.style.left = position.x + 'px'
  202. imgDom.style.top = position.y + 'px'
  203. }
  204. }
  205. // 图片加载完成后绘制图片
  206. const drawImage = () => {
  207. const canvas = paperCanvasRef.value
  208. if (!canvas) return
  209. const ctx = canvas.getContext('2d')
  210. if (!ctx) return
  211. ctx.clearRect(0, 0, canvasInfo.width, canvasInfo.height) // 清除边框数据
  212. ImageInfoChange() // 用image图片替代背景
  213. for (let i = 0; i < props.drawData.length; i++) {
  214. let point = {
  215. x: props.drawData[i].x * zoomRate.value * scale.value,
  216. y: props.drawData[i].y * zoomRate.value * scale.value,
  217. width: props.drawData[i].w * zoomRate.value * scale.value,
  218. height: props.drawData[i].h * zoomRate.value * scale.value,
  219. blockName: props.drawData[i].blockName,
  220. page: props.drawData[i].page,
  221. blockArea: props.drawData[i].blockArea,
  222. }
  223. // 外边框数据 mm单位 比如系统卡 使用
  224. if (props.drawData[i].usedCardType == 1) {
  225. // 系统卡
  226. const imageInfo = paperImgInfo
  227. let offsexPoint = {
  228. x: props.drawData[i].x - 30,
  229. y: props.drawData[i].y - 25,
  230. w: props.drawData[i].w,
  231. h: props.drawData[i].h,
  232. } // 相对与第一个黑块的坐标
  233. let templateInfo = {
  234. width: 794 - 30 * 2,
  235. height: 1123 - 25 * 2,
  236. } // 模板去掉边框的尺寸
  237. // 如果长大于宽 就是A3 否则就是A4
  238. if (paperImgInfo.width > paperImgInfo.height) {
  239. // A3 1588*1123
  240. offsexPoint = {
  241. x: props.drawData[i].x - 30,
  242. y: props.drawData[i].y - 25,
  243. w: props.drawData[i].w,
  244. h: props.drawData[i].h,
  245. }
  246. templateInfo = {
  247. width: 1588 - 30 * 2,
  248. height: 1123 - 25 * 2,
  249. }
  250. } else {
  251. // A4 794*1123
  252. offsexPoint = {
  253. x: props.drawData[i].x - 30,
  254. y: props.drawData[i].y - 25,
  255. w: props.drawData[i].w,
  256. h: props.drawData[i].h,
  257. }
  258. templateInfo = {
  259. width: 794 - 30 * 2,
  260. height: 1123 - 25 * 2,
  261. }
  262. }
  263. // 系统卡 需要根据模板的坐标进行转换
  264. const x = parseFloat(
  265. ((offsexPoint.x / templateInfo.width) * imageInfo.width).toFixed(2)
  266. )
  267. const y = parseFloat(
  268. ((offsexPoint.y / templateInfo.height) * imageInfo.height).toFixed(2)
  269. )
  270. const w = parseFloat(
  271. ((props.drawData[i].w / templateInfo.width) * imageInfo.width).toFixed(2)
  272. )
  273. const h = parseFloat(
  274. ((props.drawData[i].h / templateInfo.height) * imageInfo.height).toFixed(2)
  275. )
  276. point = {
  277. x: x * zoomRate.value * scale.value,
  278. y: y * zoomRate.value * scale.value,
  279. width: w * zoomRate.value * scale.value,
  280. height: h * zoomRate.value * scale.value,
  281. blockName: props.drawData[i].blockName,
  282. page: props.drawData[i].page || 1,
  283. blockArea: props.drawData[i].blockArea,
  284. }
  285. }
  286. // 当前表格选择的边框换蓝色显示
  287. if (props.currentId == props.drawData[i].id) {
  288. ctx.strokeStyle = 'blue'
  289. ctx.fillStyle = 'blue'
  290. ctx.lineWidth = 2
  291. } else {
  292. ctx.strokeStyle = 'red'
  293. ctx.fillStyle = 'red'
  294. ctx.lineWidth = 2
  295. }
  296. const topiclist = props.drawData[i].topiclist || []
  297. if (topiclist.length > 0) {
  298. const topicName = String(topiclist?.[0]?.topicName)
  299. // 客观题有题目则不显示外边框
  300. if (topicName.includes('选做区')) {
  301. ctx.strokeRect(point.x, point.y, point.width, point.height) // 绘制边框
  302. }
  303. if (topiclist[0].index < 0) {
  304. // 系统卡选做题显示边框
  305. ctx.strokeRect(point.x, point.y, point.width, point.height) // 绘制边框
  306. }
  307. } else {
  308. ctx.lineWidth = 2
  309. ctx.strokeRect(point.x, point.y, point.width, point.height) // 绘制边框
  310. }
  311. // 客观题小题加载
  312. if (props.drawData[i].topiclist) {
  313. const topiclist = props.drawData[i].topiclist
  314. let derection = 1 // 排列方向 1 横向 2竖向
  315. if (props.drawData[i].areaOption) {
  316. try {
  317. const option = JSON.parse(props.drawData[i].areaOption)
  318. derection = option.derection
  319. } catch (e) {
  320. console.error('解析 areaOption 失败', e)
  321. }
  322. }
  323. if (isShowDraw.value) {
  324. topiclist.forEach((topicItem) => {
  325. ctx.font = '15px Arial'
  326. ctx.fillStyle = 'red'
  327. ctx.textAlign = 'left'
  328. if (String(topicItem.topicName).includes('选做区')) {
  329. // 选做题不显示题目文字
  330. } else {
  331. let namePoint = {
  332. x: topicItem.answerList[0].x,
  333. y: topicItem.answerList[0].y,
  334. }
  335. // 绘制文字 系统卡
  336. if (props.drawData[i].usedCardType == 1) {
  337. const imageInfo = paperImgInfo
  338. let offsexPoint = {
  339. x: topicItem.answerList[0].x - 30,
  340. y: topicItem.answerList[0].y - 25,
  341. }
  342. let templateInfo = {
  343. width: 794 - 30 * 2,
  344. height: 1123 - 25 * 2,
  345. }
  346. if (paperImgInfo.width > paperImgInfo.height) {
  347. // A3
  348. offsexPoint = {
  349. x: topicItem.answerList[0].x - 30,
  350. y: topicItem.answerList[0].y - 25,
  351. }
  352. templateInfo = {
  353. width: 1588 - 30 * 2,
  354. height: 1123 - 25 * 2,
  355. }
  356. } else {
  357. // A4
  358. offsexPoint = {
  359. x: topicItem.answerList[0].x - 30,
  360. y: topicItem.answerList[0].y - 25,
  361. }
  362. templateInfo = {
  363. width: 794 - 30 * 2,
  364. height: 1123 - 25 * 2,
  365. }
  366. }
  367. namePoint.x = parseFloat(
  368. ((offsexPoint.x / templateInfo.width) * imageInfo.width).toFixed(2)
  369. )
  370. namePoint.y = parseFloat(
  371. ((offsexPoint.y / templateInfo.height) * imageInfo.height).toFixed(2)
  372. )
  373. }
  374. if (props.isAbnormal) {
  375. if (topicItem.hasAbnormal) {
  376. ctx.fillStyle = 'red'
  377. } else {
  378. ctx.fillStyle = 'blue'
  379. }
  380. } else {
  381. ctx.fillStyle = 'red'
  382. if (props.currentId == props.drawData[i].id) {
  383. ctx.fillStyle = 'blue'
  384. } else {
  385. ctx.fillStyle = 'red'
  386. }
  387. }
  388. if (derection == 1 || derection == 3) {
  389. // 横向 或者横纵向
  390. ctx.fillText(
  391. topicItem.topicName,
  392. parseFloat(namePoint.x - 30) * zoomRate.value * scale.value,
  393. parseFloat(namePoint.y + 15) * zoomRate.value * scale.value
  394. )
  395. } else {
  396. // 竖向
  397. ctx.fillText(
  398. topicItem.topicName,
  399. parseFloat(namePoint.x + 1) * zoomRate.value * scale.value,
  400. parseFloat(namePoint.y - 22) * zoomRate.value * scale.value
  401. )
  402. }
  403. }
  404. topicItem.answerList.forEach((answerItem) => {
  405. let obj = {
  406. x: answerItem.x * zoomRate.value * scale.value,
  407. y: answerItem.y * zoomRate.value * scale.value,
  408. width: answerItem.w * zoomRate.value * scale.value,
  409. height: answerItem.h * zoomRate.value * scale.value,
  410. }
  411. // 系统卡
  412. if (props.drawData[i].usedCardType == 1) {
  413. const imageInfo = paperImgInfo
  414. let offsexPoint = {
  415. x: answerItem.x - 30,
  416. y: answerItem.y - 25,
  417. w: answerItem.w,
  418. h: answerItem.h,
  419. }
  420. let templateInfo = {
  421. width: 794 - 30 * 2,
  422. height: 1123 - 25 * 2,
  423. }
  424. if (paperImgInfo.width > paperImgInfo.height) {
  425. // A3
  426. offsexPoint = {
  427. x: answerItem.x - 30,
  428. y: answerItem.y - 25,
  429. w: answerItem.w,
  430. h: answerItem.h,
  431. }
  432. templateInfo = {
  433. width: 1588 - 30 * 2,
  434. height: 1123 - 25 * 2,
  435. }
  436. } else {
  437. // A4
  438. offsexPoint = {
  439. x: answerItem.x - 30,
  440. y: answerItem.y - 25,
  441. w: answerItem.w,
  442. h: answerItem.h,
  443. }
  444. templateInfo = {
  445. width: 794 - 30 * 2,
  446. height: 1123 - 25 * 2,
  447. }
  448. }
  449. const x = parseFloat(
  450. ((offsexPoint.x / templateInfo.width) * imageInfo.width).toFixed(2)
  451. )
  452. const y = parseFloat(
  453. ((offsexPoint.y / templateInfo.height) * imageInfo.height).toFixed(2)
  454. )
  455. const w = parseFloat(
  456. ((answerItem.w / templateInfo.width) * imageInfo.width).toFixed(2)
  457. )
  458. const h = parseFloat(
  459. ((answerItem.h / templateInfo.height) * imageInfo.height).toFixed(2)
  460. )
  461. obj = {
  462. x: x * zoomRate.value * scale.value,
  463. y: y * zoomRate.value * scale.value,
  464. width: w * zoomRate.value * scale.value,
  465. height: h * zoomRate.value * scale.value,
  466. }
  467. }
  468. ctx.lineWidth = 1
  469. // 选中 绘制带背景阴影的边框
  470. if (topicItem.hasAbnormal) {
  471. // 有异常的
  472. if (answerItem.isCheck) {
  473. ctx.fillStyle = 'rgba(255,0,0,0.3)' // 红色背景
  474. ctx.strokeStyle = 'red' // 红色边框
  475. ctx.fillRect(obj.x, obj.y, obj.width, obj.height)
  476. ctx.strokeRect(obj.x, obj.y, obj.width, obj.height)
  477. } else {
  478. ctx.fillStyle = 'red' // 半透明红色背景
  479. ctx.strokeStyle = 'red' // 边框的颜色
  480. ctx.strokeRect(obj.x, obj.y, obj.width, obj.height)
  481. }
  482. } else {
  483. if (props.isAbnormal) {
  484. ctx.fillStyle = 'rgba(0,0,255,0.3)' // 半透明蓝色背景
  485. ctx.strokeStyle = 'blue' // 蓝色边框
  486. } else {
  487. ctx.fillStyle = 'rgba(255,0,0,0.3)' // 红色背景
  488. ctx.strokeStyle = 'red' // 红色边框
  489. if (props.currentId == props.drawData[i].id) {
  490. ctx.strokeStyle = 'blue'
  491. } else {
  492. ctx.strokeStyle = 'red'
  493. }
  494. }
  495. // 选中
  496. if (answerItem?.isCheck) {
  497. ctx.fillRect(obj.x, obj.y, obj.width, obj.height)
  498. ctx.strokeRect(obj.x, obj.y, obj.width, obj.height)
  499. } else {
  500. ctx.strokeRect(obj.x, obj.y, obj.width, obj.height)
  501. }
  502. }
  503. })
  504. })
  505. }
  506. }
  507. // 主观题划分区加载
  508. if (props.drawData[i].scoreList) {
  509. const scoreList = props.drawData[i].scoreList
  510. scoreList.forEach((scoreItem) => {
  511. ctx.font = '15px Arial'
  512. ctx.fillStyle = 'red'
  513. ctx.textAlign = 'left'
  514. let obj = {
  515. x: scoreItem.x * zoomRate.value * scale.value,
  516. y: scoreItem.y * zoomRate.value * scale.value,
  517. width: scoreItem.w * zoomRate.value * scale.value,
  518. height: scoreItem.h * zoomRate.value * scale.value,
  519. }
  520. // 系统卡
  521. if (props.drawData[i].usedCardType == 1) {
  522. const imageInfo = paperImgInfo
  523. let offsexPoint = {
  524. x: scoreItem.x - 30,
  525. y: scoreItem.y - 25,
  526. w: scoreItem.w,
  527. h: scoreItem.h,
  528. }
  529. let templateInfo = {
  530. width: 794 - 30 * 2,
  531. height: 1123 - 25 * 2,
  532. }
  533. if (paperImgInfo.width > paperImgInfo.height) {
  534. // A3
  535. offsexPoint = {
  536. x: scoreItem.x - 30,
  537. y: scoreItem.y - 25,
  538. w: scoreItem.w,
  539. h: scoreItem.h,
  540. }
  541. templateInfo = {
  542. width: 1588 - 30 * 2,
  543. height: 1123 - 25 * 2,
  544. }
  545. } else {
  546. // A4
  547. offsexPoint = {
  548. x: scoreItem.x - 30,
  549. y: scoreItem.y - 25,
  550. w: scoreItem.w,
  551. h: scoreItem.h,
  552. }
  553. templateInfo = {
  554. width: 794 - 30 * 2,
  555. height: 1123 - 25 * 2,
  556. }
  557. }
  558. const x = parseFloat(
  559. ((offsexPoint.x / templateInfo.width) * imageInfo.width).toFixed(2)
  560. )
  561. const y = parseFloat(
  562. ((offsexPoint.y / templateInfo.height) * imageInfo.height).toFixed(2)
  563. )
  564. const w = parseFloat(
  565. ((scoreItem.w / templateInfo.width) * imageInfo.width).toFixed(2)
  566. )
  567. const h = parseFloat(
  568. ((scoreItem.h / templateInfo.height) * imageInfo.height).toFixed(2)
  569. )
  570. obj = {
  571. x: x * zoomRate.value * scale.value,
  572. y: y * zoomRate.value * scale.value,
  573. width: w * zoomRate.value * scale.value,
  574. height: h * zoomRate.value * scale.value,
  575. }
  576. }
  577. ctx.lineWidth = 1
  578. ctx.fillStyle = scoreItem.color + '50' // 红色背景
  579. ctx.strokeStyle = scoreItem.color // 红色边框
  580. if (scoreItem.isCheck) {
  581. ctx.fillRect(obj.x, obj.y, obj.width, obj.height)
  582. }
  583. ctx.strokeRect(obj.x, obj.y, obj.width, obj.height) // 绘制答案边框
  584. })
  585. }
  586. ctx.fillStyle = 'rgba(255,0,0,1)' // 半透明红色背景
  587. ctx.font = '15px Arial'
  588. ctx.textAlign = 'left'
  589. // 绘制文字 定位去和客观题组不用显示
  590. if (point.blockArea != 2 && point.blockArea != 8) {
  591. ctx.fillText(point.blockName, point.x, point.y - 5)
  592. }
  593. }
  594. ctx.restore()
  595. }
  596. // 更新缩放率
  597. const updateZoomAndPaperInfo = () => {
  598. const widthZoomRate = containerWidth.value / paperImgInfo.width
  599. const heightZoomRate = containerHeight.value / paperImgInfo.height
  600. // 选择较小的缩放率作为基准,确保图像完整显示在容器内
  601. zoomRate.value = Math.min(widthZoomRate, heightZoomRate)
  602. }
  603. // 更新画布尺寸
  604. const updateCanvasSize = () => {
  605. canvasInfo.width = Math.round(paperImgInfo.width * zoomRate.value * scale.value)
  606. canvasInfo.height = Math.round(paperImgInfo.height * zoomRate.value * scale.value)
  607. if (paperCanvasRef.value) {
  608. paperCanvasRef.value.width = canvasInfo.width
  609. paperCanvasRef.value.height = canvasInfo.height
  610. }
  611. }
  612. // 中心化画布
  613. const centerCanvas = () => {
  614. position.x = (containerWidth.value - canvasInfo.width) / 2
  615. position.y = (containerHeight.value - canvasInfo.height) / 2
  616. if (paperCanvasRef.value) {
  617. paperCanvasRef.value.style.left = `${position.x}px`
  618. paperCanvasRef.value.style.top = `${position.y}px`
  619. }
  620. isInit.value = false // 后面不在执行居中操作
  621. }
  622. // 全局鼠标释放事件处理
  623. const onGlobalMouseUp = () => {
  624. isDragging.value = false
  625. }
  626. // 鼠标按下事件
  627. const onMouseDown = (event) => {
  628. // 只响应左键点击(button值为0表示左键)
  629. if (event.button !== 0) {
  630. isDragging.value = false
  631. return
  632. }
  633. // 确保在开始新的拖拽前,先重置拖拽状态
  634. isDragging.value = false
  635. if (drawType.value == 0) {
  636. if (props.isDrag) {
  637. isDragging.value = true
  638. startX.value = event.clientX - position.x
  639. startY.value = event.clientY - position.y
  640. }
  641. }
  642. if (drawType.value == 1) {
  643. if (event.target.id != 'paperCanvas') {
  644. isDragging.value = true
  645. startX.value = event.clientX - position.x
  646. startY.value = event.clientY - position.y
  647. }
  648. }
  649. }
  650. // 鼠标移动事件
  651. const onMouseMove = (event) => {
  652. if (isDragging.value) {
  653. // 在拖拽模式下,移动画布
  654. position.x = event.clientX - startX.value
  655. position.y = event.clientY - startY.value
  656. if (paperCanvasRef.value) {
  657. paperCanvasRef.value.style.left = `${position.x}px`
  658. paperCanvasRef.value.style.top = `${position.y}px`
  659. }
  660. ImageInfoChange()
  661. }
  662. }
  663. // 鼠标抬起事件
  664. const onMouseUp = (event) => {
  665. // 只响应左键释放
  666. if (event && event.button !== 0) {
  667. return
  668. }
  669. isDragging.value = false // 停止拖拽
  670. }
  671. // 鼠标滚轮事件
  672. const onWheel = (event) => {
  673. event.preventDefault()
  674. // 计算新的缩放比例
  675. const delta = event.deltaY < 0 ? 1 : -1
  676. const newScale = scale.value + delta * 0.1
  677. const clampedScale = Math.max(minScale, Math.min(maxScale, newScale))
  678. // 如果缩放值没有变化,则直接返回
  679. if (clampedScale === scale.value) return
  680. // 获取鼠标在容器中的位置
  681. const containerRect = mainContainerRef.value.getBoundingClientRect()
  682. const mouseX = event.clientX - containerRect.left
  683. const mouseY = event.clientY - containerRect.top
  684. // 计算鼠标相对于当前画布的位置
  685. const mouseRelativeToCanvasX = (mouseX - position.x) / scale.value
  686. const mouseRelativeToCanvasY = (mouseY - position.y) / scale.value
  687. // 更新缩放比例
  688. scale.value = clampedScale
  689. // 更新画布尺寸
  690. updateCanvasSize()
  691. // 重新计算画布位置,使缩放围绕鼠标点进行
  692. position.x = mouseX - mouseRelativeToCanvasX * scale.value
  693. position.y = mouseY - mouseRelativeToCanvasY * scale.value
  694. // 应用新的位置
  695. if (paperCanvasRef.value) {
  696. paperCanvasRef.value.style.left = `${position.x}px`
  697. paperCanvasRef.value.style.top = `${position.y}px`
  698. }
  699. // 更新图片位置信息
  700. ImageInfoChange()
  701. // 重新绘制内容
  702. drawImage()
  703. }
  704. // 鼠标复位
  705. const MouseReset = () => {
  706. drawType.value = 0
  707. const canvas = paperCanvasRef.value
  708. if (canvas) {
  709. canvas.style.cursor = 'pointer'
  710. canvas.onmousedown = null
  711. canvas.onmousemove = null
  712. canvas.onmouseup = null
  713. }
  714. }
  715. // 框选答案区域
  716. const SelectionBox = () => {
  717. drawType.value = 1
  718. const canvas = paperCanvasRef.value
  719. if (canvas) {
  720. canvas.style.cursor = 'crosshair'
  721. canvas.onmousedown = onCanvasDown
  722. canvas.onmousemove = onCanvasMove
  723. canvas.onmouseup = onCanvasUp
  724. }
  725. }
  726. // 画圈模式
  727. const PaintingCircle = () => {
  728. drawType.value = 1
  729. const canvas = paperCanvasRef.value
  730. if (canvas) {
  731. canvas.style.cursor = 'crosshair'
  732. canvas.onmousedown = onCanvasDown
  733. canvas.onmousemove = onCanvasMove
  734. canvas.onmouseup = onCanvasUp
  735. }
  736. }
  737. // 开始画答案区域圈
  738. const StartPaintingCircle = (obj) => {
  739. Object.assign(addObjectAreaOption, obj)
  740. drawType.value = 1
  741. const canvas = paperCanvasRef.value
  742. if (canvas) {
  743. canvas.style.cursor = 'crosshair'
  744. canvas.onmousedown = onCanvasDown
  745. canvas.onmousemove = onCanvasMove
  746. canvas.onmouseup = onCanvasUp
  747. }
  748. }
  749. // canvas上按下事件
  750. const onCanvasDown = (e) => {
  751. const canvas = paperCanvasRef.value
  752. if (canvas) canvas.style.cursor = 'crosshair'
  753. isDrawing.value = true
  754. isDragging.value = false // 禁止拖拽
  755. rectPoint.startX = e.offsetX
  756. rectPoint.startY = e.offsetY
  757. rectPoint.endX = e.offsetX
  758. rectPoint.endY = e.offsetY
  759. }
  760. // canvas上移动事件
  761. const onCanvasMove = (e) => {
  762. if (isDrawing.value) {
  763. const canvas = paperCanvasRef.value
  764. if (!canvas) return
  765. const ctx = canvas.getContext('2d')
  766. if (!ctx) return
  767. // 更新当前鼠标位置
  768. rectPoint.endX = e.offsetX
  769. rectPoint.endY = e.offsetY
  770. // 清除之前的矩形
  771. ctx.clearRect(0, 0, canvas.width, canvas.height)
  772. // 重新绘制之前的边框数据
  773. drawImage()
  774. // 设置矩形样式
  775. ctx.strokeStyle = 'blue'
  776. ctx.lineWidth = 1 // 画框的线的粗细
  777. // 计算矩形的起点和宽高,处理反向框选
  778. const startX = rectPoint.startX
  779. const startY = rectPoint.startY
  780. const width = e.offsetX - startX
  781. const height = e.offsetY - startY
  782. // 绘制矩形(可以处理负宽度和高度)
  783. ctx.strokeRect(startX, startY, width, height)
  784. }
  785. }
  786. // canvas抬起事件
  787. const onCanvasUp = () => {
  788. isDrawing.value = false
  789. // 处理反向框选,确保宽度和高度为正数
  790. let startX = rectPoint.startX
  791. let startY = rectPoint.startY
  792. let endX = rectPoint.endX
  793. let endY = rectPoint.endY
  794. // 确保起点坐标是左上角,终点坐标是右下角
  795. let actualStartX = Math.min(startX, endX)
  796. let actualStartY = Math.min(startY, endY)
  797. let actualEndX = Math.max(startX, endX)
  798. let actualEndY = Math.max(startY, endY)
  799. // 计算实际的宽度和高度(确保为正数)
  800. let width = Math.abs(actualEndX - actualStartX)
  801. let height = Math.abs(actualEndY - actualStartY)
  802. const point = {
  803. x: floatNum(actualStartX / zoomRate.value / scale.value),
  804. y: floatNum(actualStartY / zoomRate.value / scale.value),
  805. w: floatNum(width / zoomRate.value / scale.value),
  806. h: floatNum(height / zoomRate.value / scale.value),
  807. unit: 'px',
  808. }
  809. Object.assign(currenPoint, point)
  810. // 只有当宽度和高度都大于0时才触发事件
  811. if (point.w > 0 && point.h > 0) {
  812. emit('GetRectPoint', point)
  813. }
  814. }
  815. // 鼠标离开画布事件
  816. const onCanvasLeave = () => {
  817. // 如果正在绘制,自动结束绘制
  818. if (isDrawing.value) {
  819. onCanvasUp()
  820. }
  821. }
  822. // 监听窗口大小变化,重新计算表格高度 使用节流防止频繁改变窗口大小导致计算量过大而页面卡顿
  823. const handleResize = throttle(() => {
  824. // 如果需要重新计算,可以在这里 uncomment
  825. // if (mainContainerRef.value) {
  826. // const { width, height } = mainContainerRef.value.getBoundingClientRect()
  827. // containerWidth.value = width
  828. // containerHeight.value = height
  829. // updateZoomAndPaperInfo()
  830. // updateCanvasSize()
  831. // centerCanvas()
  832. // loadImage()
  833. // }
  834. }, 500)
  835. // Watchers
  836. watch(
  837. () => props.paperImgUrl,
  838. (newVal) => {
  839. loading.value = true // 加载图片
  840. initData() // 初始化数据
  841. setTimeout(() => {
  842. if (!props.paperImgUrl) {
  843. loading.value = false // 加载图片完成
  844. }
  845. }, 100)
  846. },
  847. { deep: true, immediate: true }
  848. )
  849. watch(
  850. () => props.drawData,
  851. () => {
  852. drawImage() // 更新边框数据并重新绘制
  853. },
  854. { deep: true }
  855. )
  856. watch(
  857. () => props.currentId,
  858. () => {
  859. drawImage() // 更新边框数据并重新绘制
  860. }
  861. )
  862. // Lifecycle Hooks
  863. onMounted(() => {
  864. window.addEventListener('resize', handleResize)
  865. // 添加全局鼠标释放事件监听器,处理异常情况
  866. window.addEventListener('mouseup', onGlobalMouseUp)
  867. // 初始数据处理加载 如定位 居中 等 第一次居中
  868. initData()
  869. })
  870. onBeforeUnmount(() => {
  871. // 移除监听 防止内存泄漏
  872. window.removeEventListener('resize', handleResize)
  873. window.removeEventListener('mouseup', onGlobalMouseUp)
  874. })
  875. // 暴露方法给父组件(如果需要)
  876. defineExpose({
  877. ShowDrawData,
  878. fitScreen,
  879. MouseReset,
  880. SelectionBox,
  881. PaintingCircle,
  882. StartPaintingCircle,
  883. })
  884. </script>
  885. <style lang="scss" scoped>
  886. .paper_canvas {
  887. position: absolute;
  888. cursor: pointer;
  889. background-color: transparent;
  890. z-index: 10; // 使canvas在图片上方
  891. // border:1px solid green;
  892. }
  893. .img_container {
  894. position: absolute;
  895. cursor: pointer;
  896. z-index: 9;
  897. // border:1px solid red;
  898. img {
  899. width: 100%;
  900. height: 100%;
  901. }
  902. }
  903. .main_container {
  904. position: relative;
  905. overflow: hidden;
  906. }
  907. </style>