CommonMarkBody.vue 17 KB

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