CommonMarkBody.vue 17 KB

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