CommonMarkBody.vue 16 KB

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