2
0

CommonMarkBody.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. <template>
  2. <div
  3. class="mark-body-container tw-flex-auto tw-p-2 tw-relative"
  4. ref="dragContainer"
  5. >
  6. <div v-if="!store.currentTask" class="tw-text-center">
  7. {{ store.message }}
  8. </div>
  9. <div
  10. v-else-if="store.setting.examType === 'SCAN_IMAGE'"
  11. :style="{ width: answerPaperScale }"
  12. >
  13. <div
  14. style="
  15. top: 0px;
  16. right: 0;
  17. position: absolute;
  18. color: red;
  19. pointer-events: none;
  20. font-size: 30px;
  21. z-index: 1000;
  22. "
  23. >
  24. {{ markStatus }}
  25. </div>
  26. <div
  27. v-for="(item, index) in sliceImagesWithTrackList"
  28. :key="index"
  29. class="single-image-container"
  30. >
  31. <img
  32. :src="item.url"
  33. @click="(event) => innerMakeTrack(event, item)"
  34. draggable="false"
  35. @contextmenu="showBigImage"
  36. />
  37. <MarkDrawTrack
  38. :track-list="item.trackList"
  39. :special-tag-list="item.tagList"
  40. :slice-image="item.sliceImage"
  41. :dx="item.dx"
  42. :dy="item.dy"
  43. />
  44. <hr class="image-seperator" />
  45. </div>
  46. </div>
  47. <div v-else-if="store.setting.examType === 'MULTI_MEDIA'">
  48. <MultiMediaMarkBody />
  49. </div>
  50. <div v-else>impossible</div>
  51. </div>
  52. <slot name="slot-cursor" />
  53. </template>
  54. <script setup lang="ts">
  55. import {
  56. computed,
  57. onMounted,
  58. onUnmounted,
  59. reactive,
  60. ref,
  61. watch,
  62. watchEffect,
  63. } from "vue";
  64. import { getMarkStatus, isScanImage } from "./store";
  65. import MarkDrawTrack from "./MarkDrawTrack.vue";
  66. import type {
  67. MarkResult,
  68. MarkStore,
  69. SliceImage,
  70. SpecialTag,
  71. Track,
  72. } from "@/types";
  73. import { useTimers } from "@/setups/useTimers";
  74. import {
  75. getDataUrlForSliceConfig,
  76. getDataUrlForSplitConfig,
  77. loadImage,
  78. } from "@/utils/utils";
  79. import { dragImage } from "./use/draggable";
  80. import MultiMediaMarkBody from "./MultiMediaMarkBody.vue";
  81. import "viewerjs/dist/viewer.css";
  82. import Viewer from "viewerjs";
  83. const props = defineProps<{
  84. useMarkResult?: boolean;
  85. makeTrack: Function;
  86. store: MarkStore; // 实际上不是同一个store!!!
  87. uniquePropName: string; // TODO: 这个字段不需要了,是以前的rendering字段附带要求的
  88. }>();
  89. const emit = defineEmits(["error"]);
  90. const {
  91. useMarkResult = false,
  92. makeTrack,
  93. store,
  94. uniquePropName = "libraryId",
  95. } = props;
  96. // start: 图片拖动。在轨迹模式下,仅当没有选择分数时可用。
  97. const { dragContainer } = dragImage();
  98. // end: 图片拖动
  99. const { addTimeout } = useTimers();
  100. // start: 缩略图定位
  101. watch(
  102. () => store.minimapScrollTo,
  103. () => {
  104. const container = document.querySelector(
  105. ".mark-body-container"
  106. ) as HTMLDivElement;
  107. addTimeout(() => {
  108. if (container) {
  109. const { scrollHeight } = container;
  110. container.scrollTo({
  111. top: scrollHeight * store.minimapScrollTo,
  112. });
  113. }
  114. }, 10);
  115. }
  116. );
  117. // end: 缩略图定位
  118. // start: 快捷键定位
  119. const scrollContainerByKey = (e: KeyboardEvent) => {
  120. const container = document.querySelector(
  121. ".mark-body-container"
  122. ) as HTMLDivElement;
  123. if (e.key === "w") {
  124. container.scrollBy({ top: -100, behavior: "smooth" });
  125. } else if (e.key === "s") {
  126. container.scrollBy({ top: 100, behavior: "smooth" });
  127. } else if (e.key === "a") {
  128. container.scrollBy({ left: -100, behavior: "smooth" });
  129. } else if (e.key === "d") {
  130. container.scrollBy({ left: 100, behavior: "smooth" });
  131. }
  132. };
  133. onMounted(() => {
  134. document.addEventListener("keypress", scrollContainerByKey);
  135. });
  136. onUnmounted(() => {
  137. document.removeEventListener("keypress", scrollContainerByKey);
  138. });
  139. // end: 快捷键定位
  140. // start: 计算裁切图和裁切图上的分数轨迹和特殊标记轨迹
  141. let sliceImagesWithTrackList: Array<SliceImage> = reactive([]);
  142. let maxSliceWidth = 0; // 最大的裁切块宽度,图片容器以此为准
  143. let theFinalHeight = 0; // 最终宽度,用来定位轨迹在第几张图片,不包括image-seperator高度
  144. async function getImageUsingDataUrl(
  145. dataUrl: string
  146. ): Promise<HTMLImageElement> {
  147. return new Promise((resolve, reject) => {
  148. const image = new Image();
  149. image.src = dataUrl;
  150. image.onload = function () {
  151. resolve(image);
  152. };
  153. image.onerror = reject;
  154. });
  155. }
  156. async function processSliceConfig() {
  157. let markResult = store.currentMarkResult as MarkResult;
  158. if (useMarkResult) {
  159. // check if have MarkResult for currentTask
  160. if (!markResult) return;
  161. }
  162. if (!store.currentTask) return;
  163. const images = [];
  164. // 必须要先加载一遍,把“选择整图”的宽高重置后,再算总高度
  165. for (const sliceConfig of store.currentTask.sliceConfig) {
  166. const url = store.currentTask.sliceUrls[sliceConfig.i - 1];
  167. const image = await loadImage(url);
  168. images[sliceConfig.i] = image;
  169. if (sliceConfig.w === 0 && sliceConfig.h === 0) {
  170. // 选择整图时,w/h 为0
  171. sliceConfig.w = image.naturalWidth;
  172. sliceConfig.h = image.naturalHeight;
  173. }
  174. }
  175. theFinalHeight = store.currentTask.sliceConfig
  176. .map((v) => v.h)
  177. .reduce((acc, v) => (acc += v));
  178. maxSliceWidth = Math.max(...store.currentTask.sliceConfig.map((v) => v.w));
  179. // 用来保存sliceImage在整个图片容器中(不包括image-seperator)的高度范围
  180. let accumTopHeight = 0;
  181. let accumBottomHeight = 0;
  182. const tempSliceImagesWithTrackList = [] as Array<SliceImage>;
  183. for (const sliceConfig of store.currentTask.sliceConfig) {
  184. accumBottomHeight += sliceConfig.h;
  185. const url = store.currentTask.sliceUrls[sliceConfig.i - 1];
  186. const indexInSliceUrls = sliceConfig.i;
  187. const image = images[sliceConfig.i];
  188. const dataUrl = (await getDataUrlForSliceConfig(
  189. image,
  190. sliceConfig,
  191. maxSliceWidth,
  192. url
  193. )) as string;
  194. let trackLists = [] as Array<Track>;
  195. if (useMarkResult) {
  196. trackLists = markResult.trackList;
  197. } else {
  198. trackLists = store.currentTask.questionList
  199. .map((q) => q.trackList)
  200. .reduce((acc, t) => {
  201. acc = acc.concat(t);
  202. return acc;
  203. }, [] as Array<Track>);
  204. }
  205. const thisImageTrackList = trackLists.filter(
  206. (t) => t.offsetIndex === indexInSliceUrls
  207. );
  208. let tagLists = [] as Array<SpecialTag>;
  209. if (useMarkResult) {
  210. tagLists = markResult.specialTagList ?? [];
  211. } else {
  212. tagLists = store.currentTask.specialTagList ?? [];
  213. }
  214. const thisImageTagList = tagLists.filter(
  215. (t) => t.offsetIndex === indexInSliceUrls
  216. );
  217. const sliceImage = await getImageUsingDataUrl(dataUrl);
  218. tempSliceImagesWithTrackList.push({
  219. url: dataUrl,
  220. indexInSliceUrls: sliceConfig.i,
  221. // 通过positionY来定位是第几张slice的还原,并过滤出相应的track
  222. trackList: thisImageTrackList.filter(
  223. (t) =>
  224. t.positionY >= accumTopHeight / theFinalHeight &&
  225. t.positionY < accumBottomHeight / theFinalHeight
  226. ),
  227. tagList: thisImageTagList.filter(
  228. (t) =>
  229. t.positionY >= accumTopHeight / theFinalHeight &&
  230. t.positionY < accumBottomHeight / theFinalHeight
  231. ),
  232. originalImage: image,
  233. sliceImage,
  234. dx: sliceConfig.x,
  235. dy: sliceConfig.y,
  236. accumTopHeight,
  237. effectiveWidth: sliceConfig.w,
  238. });
  239. accumTopHeight = accumBottomHeight;
  240. }
  241. sliceImagesWithTrackList.push(...tempSliceImagesWithTrackList);
  242. }
  243. async function processSplitConfig() {
  244. let markResult = store.currentMarkResult as MarkResult;
  245. if (useMarkResult) {
  246. // check if have MarkResult for currentTask
  247. if (!markResult) return;
  248. }
  249. if (!store.currentTask) return;
  250. const images = [];
  251. for (const url of store.currentTask.sliceUrls) {
  252. const image = await loadImage(url);
  253. images.push(image);
  254. }
  255. const splitConfigPairs = store.setting.splitConfig
  256. .map((v, index, ary) => (index % 2 === 0 ? [v, ary[index + 1]] : false))
  257. .filter((v) => v) as unknown as Array<[number, number]>;
  258. const maxSplitConfig = Math.max(...store.setting.splitConfig);
  259. maxSliceWidth =
  260. Math.max(...images.map((v) => v.naturalWidth)) * maxSplitConfig;
  261. theFinalHeight =
  262. splitConfigPairs.length *
  263. images.reduce((acc, v) => (acc += v.naturalHeight), 0);
  264. let accumTopHeight = 0;
  265. let accumBottomHeight = 0;
  266. const tempSliceImagesWithTrackList = [] as Array<SliceImage>;
  267. for (const url of store.currentTask.sliceUrls) {
  268. for (const config of splitConfigPairs) {
  269. const indexInSliceUrls = store.currentTask.sliceUrls.indexOf(url) + 1;
  270. const image = images[indexInSliceUrls - 1];
  271. accumBottomHeight += image.naturalHeight;
  272. const dataUrl = (await getDataUrlForSplitConfig(
  273. image,
  274. config,
  275. maxSliceWidth,
  276. url
  277. )) as string;
  278. let trackLists = [] as Array<Track>;
  279. if (useMarkResult) {
  280. trackLists = markResult.trackList;
  281. } else {
  282. trackLists = store.currentTask.questionList
  283. .map((q) => q.trackList)
  284. .reduce((acc, t) => {
  285. acc = acc.concat(t);
  286. return acc;
  287. }, [] as Array<Track>);
  288. }
  289. const thisImageTrackList = trackLists.filter(
  290. (t) => t.offsetIndex === indexInSliceUrls
  291. );
  292. let tagLists = [] as Array<SpecialTag>;
  293. if (useMarkResult) {
  294. tagLists = markResult.specialTagList ?? [];
  295. } else {
  296. tagLists = store.currentTask.specialTagList ?? [];
  297. }
  298. const thisImageTagList = tagLists.filter(
  299. (t) => t.offsetIndex === indexInSliceUrls
  300. );
  301. const sliceImage = await getImageUsingDataUrl(dataUrl);
  302. tempSliceImagesWithTrackList.push({
  303. url: dataUrl,
  304. indexInSliceUrls: store.currentTask.sliceUrls.indexOf(url) + 1,
  305. trackList: thisImageTrackList.filter(
  306. (t) =>
  307. t.positionY >= accumTopHeight / theFinalHeight &&
  308. t.positionY < accumBottomHeight / theFinalHeight
  309. ),
  310. tagList: thisImageTagList.filter(
  311. (t) =>
  312. t.positionY >= accumTopHeight / theFinalHeight &&
  313. t.positionY < accumBottomHeight / theFinalHeight
  314. ),
  315. originalImage: image,
  316. sliceImage,
  317. dx: image.naturalWidth * config[0],
  318. dy: 0,
  319. accumTopHeight,
  320. effectiveWidth: image.naturalWidth * config[1],
  321. });
  322. accumTopHeight = accumBottomHeight;
  323. }
  324. }
  325. sliceImagesWithTrackList.push(...tempSliceImagesWithTrackList);
  326. }
  327. // should not render twice at the same time
  328. let renderLock = false;
  329. const renderPaperAndMark = async () => {
  330. if (!store) return;
  331. if (!isScanImage()) return;
  332. if (renderLock) {
  333. console.log("上个任务还未渲染完毕,稍等一秒再尝试渲染");
  334. await new Promise((res) => setTimeout(res, 1000));
  335. await renderPaperAndMark();
  336. return;
  337. }
  338. renderLock = true;
  339. // for (const s of sliceImagesWithTrackList) {
  340. // // console.log("revoke", s.url);
  341. // URL.revokeObjectURL(s.url);
  342. // }
  343. sliceImagesWithTrackList.splice(0);
  344. // check if have MarkResult for currentTask
  345. let markResult = store.currentMarkResult;
  346. if ((useMarkResult && !markResult) || !store.currentTask) {
  347. renderLock = false;
  348. return;
  349. }
  350. try {
  351. store.globalMask = true;
  352. const hasSliceConfig = store.currentTask?.sliceConfig?.length;
  353. if (hasSliceConfig) {
  354. await processSliceConfig();
  355. } else {
  356. await processSplitConfig();
  357. }
  358. } catch (error) {
  359. sliceImagesWithTrackList.splice(0);
  360. console.log("render error ", error);
  361. // 图片加载出错,自动加载下一个任务
  362. emit("error");
  363. } finally {
  364. renderLock = false;
  365. store.globalMask = false;
  366. }
  367. };
  368. watchEffect(renderPaperAndMark);
  369. // end: 计算裁切图和裁切图上的分数轨迹和特殊标记轨迹
  370. // start: 放大缩小和之后的滚动
  371. const answerPaperScale = computed(() => {
  372. // 放大、缩小不影响页面之前的滚动条定位
  373. let percentWidth = 0;
  374. let percentTop = 0;
  375. const container = document.querySelector(
  376. ".mark-body-container"
  377. ) as HTMLDivElement;
  378. if (container) {
  379. const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = container;
  380. percentWidth = scrollLeft / scrollWidth;
  381. percentTop = scrollTop / scrollHeight;
  382. }
  383. addTimeout(() => {
  384. if (container) {
  385. const { scrollWidth, scrollHeight } = container;
  386. container.scrollTo({
  387. left: scrollWidth * percentWidth,
  388. top: scrollHeight * percentTop,
  389. });
  390. }
  391. }, 10);
  392. const scale = store.setting.uiSetting["answer.paper.scale"];
  393. return scale * 100 + "%";
  394. });
  395. // end: 放大缩小和之后的滚动
  396. // start: 显示评分状态和清除轨迹
  397. const markStatus = ref("");
  398. if (useMarkResult) {
  399. watch(
  400. () => store.currentTask,
  401. () => {
  402. markStatus.value = getMarkStatus();
  403. }
  404. );
  405. // 清除分数轨迹
  406. watchEffect(() => {
  407. for (const track of store.removeScoreTracks) {
  408. for (const sliceImage of sliceImagesWithTrackList) {
  409. sliceImage.trackList = sliceImage.trackList.filter(
  410. (t) =>
  411. !(
  412. t.mainNumber === track.mainNumber &&
  413. t.subNumber === track.subNumber &&
  414. t.number === track.number
  415. )
  416. );
  417. }
  418. }
  419. // 清除后,删除,否则会影响下次切换
  420. store.removeScoreTracks.splice(0);
  421. });
  422. // 清除特殊标记轨迹
  423. watchEffect(() => {
  424. for (const track of store.currentMarkResult?.specialTagList || []) {
  425. for (const sliceImage of sliceImagesWithTrackList) {
  426. sliceImage.tagList = sliceImage.tagList.filter((t) =>
  427. store.currentMarkResult?.specialTagList.find(
  428. (st) =>
  429. st.offsetIndex === t.offsetIndex &&
  430. st.offsetX === t.offsetX &&
  431. st.offsetY === t.offsetY
  432. )
  433. );
  434. }
  435. }
  436. if (store.currentMarkResult?.specialTagList.length === 0) {
  437. for (const sliceImage of sliceImagesWithTrackList) {
  438. sliceImage.tagList = [];
  439. }
  440. }
  441. });
  442. }
  443. // end: 显示评分状态和清除轨迹
  444. // start: 评分
  445. const innerMakeTrack = (event: MouseEvent, item: SliceImage) => {
  446. makeTrack && makeTrack(event, item, maxSliceWidth, theFinalHeight);
  447. };
  448. // end: 评分
  449. // start: 显示大图,供查看和翻转
  450. const showBigImage = (event: MouseEvent) => {
  451. event.preventDefault();
  452. // console.log(event);
  453. let viewer: Viewer = null as unknown as Viewer;
  454. viewer && viewer.destroy();
  455. viewer = new Viewer(event.target as HTMLElement, {
  456. // inline: true,
  457. viewed() {
  458. viewer.zoomTo(1);
  459. },
  460. hidden() {
  461. viewer.destroy();
  462. },
  463. zIndex: 1000000,
  464. });
  465. viewer.show();
  466. };
  467. // end: 显示大图,供查看和翻转
  468. // onRenderTriggered(({ key, target, type }) => {
  469. // console.log({ key, target, type });
  470. // });
  471. </script>
  472. <style scoped>
  473. .mark-body-container {
  474. height: calc(100vh - 41px);
  475. overflow: auto;
  476. /* background-size: 8px 8px;
  477. background-image: linear-gradient(to right, #e7e7e7 4px, transparent 4px),
  478. linear-gradient(to bottom, transparent 4px, #e7e7e7 4px); */
  479. background-image: linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
  480. linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
  481. linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
  482. linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
  483. background-size: 20px 20px;
  484. background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
  485. }
  486. .mark-body-container img {
  487. width: 100%;
  488. }
  489. .single-image-container {
  490. position: relative;
  491. }
  492. .image-seperator {
  493. border: 2px solid transparent;
  494. }
  495. </style>