CommonMarkBody.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  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. trackLists = store.currentTask.questionList
  290. .map((q) => q.trackList)
  291. .reduce((acc, t) => {
  292. acc = acc.concat(t);
  293. return acc;
  294. }, [] as Array<Track>);
  295. }
  296. const thisImageTrackList = trackLists.filter(
  297. (t) => t.offsetIndex === indexInSliceUrls
  298. );
  299. let tagLists = [] as Array<SpecialTag>;
  300. if (useMarkResult) {
  301. tagLists = markResult.specialTagList ?? [];
  302. } else {
  303. tagLists = store.currentTask.specialTagList ?? [];
  304. }
  305. const thisImageTagList = tagLists.filter(
  306. (t) => t.offsetIndex === indexInSliceUrls
  307. );
  308. const sliceImage = await getImageUsingDataUrl(dataUrl);
  309. tempSliceImagesWithTrackList.push({
  310. url: dataUrl,
  311. indexInSliceUrls: store.currentTask.sliceUrls.indexOf(url) + 1,
  312. trackList: thisImageTrackList.filter(
  313. (t) =>
  314. t.positionY >= accumTopHeight / theFinalHeight &&
  315. t.positionY < accumBottomHeight / theFinalHeight
  316. ),
  317. tagList: thisImageTagList.filter(
  318. (t) =>
  319. t.positionY >= accumTopHeight / theFinalHeight &&
  320. t.positionY < accumBottomHeight / theFinalHeight
  321. ),
  322. originalImage: image,
  323. sliceImage,
  324. dx: image.naturalWidth * config[0],
  325. dy: 0,
  326. accumTopHeight,
  327. effectiveWidth: image.naturalWidth * config[1],
  328. });
  329. accumTopHeight = accumBottomHeight;
  330. }
  331. }
  332. sliceImagesWithTrackList.push(...tempSliceImagesWithTrackList);
  333. }
  334. // should not render twice at the same time
  335. let renderLock = false;
  336. const renderPaperAndMark = async () => {
  337. if (!store) return;
  338. if (!isScanImage()) return;
  339. if (renderLock) {
  340. console.log("上个任务还未渲染完毕,稍等一秒再尝试渲染");
  341. await new Promise((res) => setTimeout(res, 1000));
  342. await renderPaperAndMark();
  343. return;
  344. }
  345. renderLock = true;
  346. // for (const s of sliceImagesWithTrackList) {
  347. // // console.log("revoke", s.url);
  348. // URL.revokeObjectURL(s.url);
  349. // }
  350. sliceImagesWithTrackList.splice(0);
  351. // check if have MarkResult for currentTask
  352. let markResult = store.currentMarkResult;
  353. if ((useMarkResult && !markResult) || !store.currentTask) {
  354. renderLock = false;
  355. return;
  356. }
  357. try {
  358. store.globalMask = true;
  359. const hasSliceConfig = store.currentTask?.sliceConfig?.length;
  360. if (hasSliceConfig) {
  361. await processSliceConfig();
  362. } else {
  363. await processSplitConfig();
  364. }
  365. } catch (error) {
  366. sliceImagesWithTrackList.splice(0);
  367. console.trace("render error ", error);
  368. // 图片加载出错,自动加载下一个任务
  369. emit("error");
  370. } finally {
  371. renderLock = false;
  372. store.globalMask = false;
  373. }
  374. };
  375. watchEffect(renderPaperAndMark);
  376. // 在阻止渲染的情况下,watchEffect收集不到 store.currentTask 的依赖,会导致本组件不再更新
  377. watch(
  378. () => store.currentTask,
  379. () => renderPaperAndMark()
  380. );
  381. // end: 计算裁切图和裁切图上的分数轨迹和特殊标记轨迹
  382. // start: 放大缩小和之后的滚动
  383. const answerPaperScale = computed(() => {
  384. // 放大、缩小不影响页面之前的滚动条定位
  385. let percentWidth = 0;
  386. let percentTop = 0;
  387. const container = document.querySelector(
  388. ".mark-body-container"
  389. ) as HTMLDivElement;
  390. if (container) {
  391. const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = container;
  392. percentWidth = scrollLeft / scrollWidth;
  393. percentTop = scrollTop / scrollHeight;
  394. }
  395. addTimeout(() => {
  396. if (container) {
  397. const { scrollWidth, scrollHeight } = container;
  398. container.scrollTo({
  399. left: scrollWidth * percentWidth,
  400. top: scrollHeight * percentTop,
  401. });
  402. }
  403. }, 10);
  404. const scale = store.setting.uiSetting["answer.paper.scale"];
  405. return scale * 100 + "%";
  406. });
  407. // end: 放大缩小和之后的滚动
  408. // start: 显示评分状态和清除轨迹
  409. const markStatus = ref("");
  410. if (useMarkResult) {
  411. watch(
  412. () => store.currentTask,
  413. () => {
  414. markStatus.value = getMarkStatus();
  415. }
  416. );
  417. // 清除分数轨迹
  418. watchEffect(() => {
  419. for (const track of store.removeScoreTracks) {
  420. for (const sliceImage of sliceImagesWithTrackList) {
  421. sliceImage.trackList = sliceImage.trackList.filter(
  422. (t) =>
  423. !(
  424. t.mainNumber === track.mainNumber &&
  425. t.subNumber === track.subNumber &&
  426. t.number === track.number
  427. )
  428. );
  429. }
  430. }
  431. // 清除后,删除,否则会影响下次切换
  432. store.removeScoreTracks.splice(0);
  433. });
  434. // 清除特殊标记轨迹
  435. watchEffect(() => {
  436. for (const track of store.currentMarkResult?.specialTagList || []) {
  437. for (const sliceImage of sliceImagesWithTrackList) {
  438. sliceImage.tagList = sliceImage.tagList.filter((t) =>
  439. store.currentMarkResult?.specialTagList.find(
  440. (st) =>
  441. st.offsetIndex === t.offsetIndex &&
  442. st.offsetX === t.offsetX &&
  443. st.offsetY === t.offsetY
  444. )
  445. );
  446. }
  447. }
  448. if (store.currentMarkResult?.specialTagList.length === 0) {
  449. for (const sliceImage of sliceImagesWithTrackList) {
  450. sliceImage.tagList = [];
  451. }
  452. }
  453. });
  454. }
  455. // end: 显示评分状态和清除轨迹
  456. // start: 评分
  457. const innerMakeTrack = (event: MouseEvent, item: SliceImage) => {
  458. makeTrack && makeTrack(event, item, maxSliceWidth, theFinalHeight);
  459. };
  460. // end: 评分
  461. // start: 显示大图,供查看和翻转
  462. const showBigImage = (event: MouseEvent) => {
  463. event.preventDefault();
  464. // console.log(event);
  465. let viewer: Viewer = null as unknown as Viewer;
  466. viewer && viewer.destroy();
  467. viewer = new Viewer(event.target as HTMLElement, {
  468. // inline: true,
  469. viewed() {
  470. viewer.zoomTo(1);
  471. },
  472. hidden() {
  473. viewer.destroy();
  474. },
  475. zIndex: 1000000,
  476. });
  477. viewer.show();
  478. };
  479. // end: 显示大图,供查看和翻转
  480. // onRenderTriggered(({ key, target, type }) => {
  481. // console.log({ key, target, type });
  482. // });
  483. </script>
  484. <style scoped>
  485. .mark-body-container {
  486. position: relative;
  487. min-height: calc(100vh - 56px);
  488. height: calc(100vh - 56px);
  489. overflow: auto;
  490. /* background-size: 8px 8px;
  491. background-image: linear-gradient(to right, #e7e7e7 4px, transparent 4px),
  492. linear-gradient(to bottom, transparent 4px, #e7e7e7 4px); */
  493. background-color: var(--app-container-bg-color);
  494. background-image: linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
  495. linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
  496. linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
  497. linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
  498. background-size: 20px 20px;
  499. background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
  500. transform: inherit;
  501. }
  502. .mark-body-container img {
  503. width: 100%;
  504. }
  505. .empty-task {
  506. width: calc(100%);
  507. height: calc(100%);
  508. font-size: 20px;
  509. overflow: hidden;
  510. background-color: var(--app-container-bg-color);
  511. }
  512. .empty-task img {
  513. display: block;
  514. width: 288px;
  515. height: 225px;
  516. clip-path: polygon(0 0, 0 80%, 100% 80%, 100% 0);
  517. }
  518. .single-image-container {
  519. position: relative;
  520. }
  521. .image-seperator {
  522. border: 2px solid transparent;
  523. }
  524. .status-container {
  525. position: fixed;
  526. top: 56px;
  527. right: 340px;
  528. color: white;
  529. pointer-events: none;
  530. font-size: var(--app-title-font-size);
  531. background-color: #ef7c78;
  532. width: 30px;
  533. height: 50px;
  534. text-align: center;
  535. z-index: 1000;
  536. }
  537. .double-triangle {
  538. background-color: #ef7c78;
  539. width: 30px;
  540. height: 6px;
  541. clip-path: polygon(0 0, 0 6px, 50% 0, 100% 0, 100% 6px, 50% 0);
  542. position: absolute;
  543. bottom: -5px;
  544. }
  545. </style>