瀏覽代碼

feat: 特殊标记横线,圆圈,文字

zhangjie 2 周之前
父節點
當前提交
d926b8ef33

+ 49 - 0
src/directives/eleMove.ts

@@ -0,0 +1,49 @@
+export const vEleMoveDirective = {
+  mounted(el, { value, modifiers }) {
+    let [_x, _y] = [0, 0];
+    // 只允许鼠标左键触发
+    const moveHandle = function (e) {
+      if (e.button !== 0) return;
+      if (modifiers.prevent) {
+        e.preventDefault();
+      }
+      if (modifiers.stop) {
+        e.stopPropagation();
+      }
+
+      const left = e.pageX - _x;
+      const top = e.pageY - _y;
+
+      value.moveElement({ left, top });
+    };
+
+    const upHandle = function (e) {
+      if (e.button !== 0) return;
+      if (modifiers.prevent) {
+        e.preventDefault();
+      }
+      if (modifiers.stop) {
+        e.stopPropagation();
+      }
+      value.moveStop && value.moveStop(e);
+      document.removeEventListener("mousemove", moveHandle);
+      document.removeEventListener("mouseup", upHandle);
+    };
+
+    el.addEventListener("mousedown", function (e) {
+      if (e.button !== 0) return;
+      if (modifiers.prevent) {
+        e.preventDefault();
+      }
+      if (modifiers.stop) {
+        e.stopPropagation();
+      }
+      _x = e.pageX;
+      _y = e.pageY;
+      value.moveStart && value.moveStart(e);
+
+      document.addEventListener("mousemove", moveHandle);
+      document.addEventListener("mouseup", upHandle);
+    });
+  },
+};

+ 2 - 0
src/features/mark/CommonMarkBody.vue

@@ -35,6 +35,7 @@
           :dx="item.dx"
           :dy="item.dy"
         />
+        <MarkBodySepecialTag :sliceImageItem="item" />
         <hr class="image-seperator" />
       </div>
     </div>
@@ -100,6 +101,7 @@ import {
 } from "@/utils/utils";
 import { dragImage } from "./use/draggable";
 import MultiMediaMarkBody from "./MultiMediaMarkBody.vue";
+import MarkBodySepecialTag from "./MarkBodySepecialTag.vue";
 import "viewerjs/dist/viewer.css";
 import Viewer from "viewerjs";
 import ZoomPaper from "@/components/ZoomPaper.vue";

+ 1 - 0
src/features/mark/MarkBoardTrack.vue

@@ -543,6 +543,7 @@ function chooseScore(score: number) {
   } else {
     store.currentScore = score;
     store.currentSpecialTag = undefined;
+    store.currentSpecialTagType = undefined;
   }
 }
 

+ 28 - 1
src/features/mark/MarkBody.vue

@@ -6,7 +6,19 @@
   />
   <div class="cursor">
     <div class="cursor-border">
-      <span class="text">{{
+      <span
+        v-if="store.currentSpecialTagType === 'TEXT'"
+        class="text text-edit"
+      ></span>
+      <span
+        v-else-if="
+          store.currentSpecialTagType === 'LINE' ||
+          store.currentSpecialTagType === 'CIRCLE'
+        "
+        class="point"
+      >
+      </span>
+      <span v-else class="text">{{
         store.currentSpecialTag ||
         (Object.is(store.currentScore, -0) ? "空" : store.currentScore)
       }}</span>
@@ -351,6 +363,21 @@ onUnmounted(() => {
   opacity: 0;
   transition: opacity 80ms cubic-bezier(0.23, 1, 0.32, 1);
 }
+.cursor .point {
+  display: inline-block;
+  vertical-align: middle;
+  width: 4px;
+  height: 4px;
+  border-radius: 50%;
+  background: red;
+}
+.cursor .text-edit {
+  display: inline-block;
+  vertical-align: middle;
+  width: 0;
+  height: 20px;
+  border-left: 2px solid #ff5050;
+}
 
 .cursor.cursor--off-screen {
   opacity: 0;

+ 286 - 0
src/features/mark/MarkBodySepecialTag.vue

@@ -0,0 +1,286 @@
+<template>
+  <div
+    v-if="isCustomSpecialTag"
+    v-ele-move-directive.stop.prevent="{
+      moveStart: (event) => specialMouseStart(event),
+      moveElement: specialMouseMove,
+      moveStop: specialMouseStop,
+    }"
+    class="image-canvas"
+    @click="(event) => canvasClick(event)"
+  >
+    <template v-if="curSliceImagesWithTrackItem?.url === sliceImageItem.url">
+      <div
+        v-if="store.currentSpecialTagType === 'LINE'"
+        :style="specialLenStyle"
+      ></div>
+      <div
+        v-else-if="store.currentSpecialTagType === 'CIRCLE'"
+        :style="specialCircleStyle"
+      ></div>
+      <div
+        v-else-if="store.currentSpecialTagType === 'TEXT'"
+        v-show="cacheTextTrack.id"
+        :id="`text-edit-box-${cacheTextTrack.id}`"
+        :key="cacheTextTrack.id"
+        class="text-edit-box"
+        contenteditable
+        :style="specialTextStyle"
+        @input="textTrackInput"
+        @blur="textTrackBlur"
+        @keypress.stop
+        @keydown.stop
+        @mousedown.stop
+        @mousemove.stop
+        @mouseup.stop
+      ></div>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { nextTick, watch } from "vue";
+import type { SliceImage, SpecialTag } from "@/types";
+import { store } from "@/store/store";
+import { randomCode } from "@/utils/utils";
+import { vEleMoveDirective } from "@/directives/eleMove";
+
+const { sliceImageItem } = defineProps<{
+  sliceImageItem: SliceImage;
+}>();
+
+const isCustomSpecialTag = $computed(() => {
+  return ["CIRCLE", "LINE", "TEXT"].includes(store.currentSpecialTagType);
+});
+
+let specialPoint = $ref({ x: 0, y: 0, ex: 0, ey: 0 });
+let curImageTarget: HTMLElement = null;
+let curSliceImagesWithTrackItem: SliceImage = $ref(null);
+let cacheTextTrack = $ref({
+  id: "",
+  x: 0,
+  y: 0,
+  maxW: 0,
+  maxY: 0,
+  content: "",
+});
+
+const specialLenStyle = $computed(() => {
+  if (specialPoint.ex <= specialPoint.x) return { display: "none" };
+
+  const width =
+    specialPoint.ex > specialPoint.x ? specialPoint.ex - specialPoint.x : 0;
+  return {
+    top: specialPoint.y + "px",
+    left: specialPoint.x + "px",
+    width: width + "px",
+    position: "absolute",
+    borderTop: "1px solid red",
+    zIndex: 9,
+  };
+});
+const specialCircleStyle = $computed(() => {
+  if (specialPoint.ex <= specialPoint.x || specialPoint.ey <= specialPoint.y)
+    return { display: "none" };
+
+  const width =
+    specialPoint.ex > specialPoint.x ? specialPoint.ex - specialPoint.x : 0;
+  const height =
+    specialPoint.ey > specialPoint.y ? specialPoint.ey - specialPoint.y : 0;
+  return {
+    top: specialPoint.y + "px",
+    left: specialPoint.x + "px",
+    width: width + "px",
+    height: height + "px",
+    position: "absolute",
+    border: "1px solid red",
+    borderRadius: "50%",
+    zIndex: 9,
+  };
+});
+
+const specialTextStyle = $computed(() => {
+  return {
+    top: cacheTextTrack.y + "px",
+    left: cacheTextTrack.x + "px",
+    minWidth: "30px",
+    minHeight: "30px",
+    maxWidth: curImageTarget.width - cacheTextTrack.x + "px",
+    maxHeight: curImageTarget.height - cacheTextTrack.y + "px",
+  };
+});
+
+function specialMouseStart(e: MouseEvent) {
+  if (store.currentSpecialTagType === "TEXT") return;
+
+  curImageTarget = e.target.parentElement.childNodes[0];
+  curSliceImagesWithTrackItem = sliceImageItem;
+  specialPoint.x = e.offsetX;
+  specialPoint.y = e.offsetY;
+}
+function specialMouseMove({ left, top }) {
+  if (store.currentSpecialTagType === "TEXT") return;
+
+  specialPoint.ex = left + specialPoint.x;
+  specialPoint.ey = top + specialPoint.y;
+}
+function specialMouseStop() {
+  if (store.currentSpecialTagType === "TEXT") return;
+
+  if (
+    store.currentSpecialTagType === "LINE" &&
+    specialPoint.ex <= specialPoint.x
+  ) {
+    specialPoint = { x: 0, y: 0, ex: 0, ey: 0 };
+    return;
+  }
+  if (
+    store.currentSpecialTagType === "CIRCLE" &&
+    (specialPoint.ex <= specialPoint.x || specialPoint.ey <= specialPoint.y)
+  ) {
+    specialPoint = { x: 0, y: 0, ex: 0, ey: 0 };
+    return;
+  }
+
+  const track: SpecialTag = {
+    tagName: "",
+    tagType: store.currentSpecialTagType,
+    offsetIndex: curSliceImagesWithTrackItem.indexInSliceUrls,
+    offsetX:
+      specialPoint.x * (curImageTarget.naturalWidth / curImageTarget.width) +
+      curSliceImagesWithTrackItem.dx,
+    offsetY:
+      specialPoint.y * (curImageTarget.naturalHeight / curImageTarget.height) +
+      curSliceImagesWithTrackItem.dy,
+  };
+
+  if (store.currentSpecialTagType === "LINE") {
+    track.tagName = JSON.stringify({
+      len:
+        (specialPoint.ex - specialPoint.x) *
+        (curImageTarget.naturalWidth / curImageTarget.width),
+    });
+  }
+  if (store.currentSpecialTagType === "CIRCLE") {
+    track.tagName = JSON.stringify({
+      width:
+        (specialPoint.ex - specialPoint.x) *
+        (curImageTarget.naturalWidth / curImageTarget.width),
+      height:
+        (specialPoint.ey - specialPoint.y) *
+        (curImageTarget.naturalHeight / curImageTarget.height),
+    });
+  }
+
+  store.currentTaskEnsured.markResult.specialTagList.push(track);
+  curSliceImagesWithTrackItem.tagList.push(track);
+  specialPoint = { x: 0, y: 0, ex: 0, ey: 0 };
+}
+
+function canvasClick(e: MouseEvent) {
+  if (cacheTextTrack.id) {
+    textTrackBlur();
+  }
+
+  const target = e.target as HTMLElement;
+  curImageTarget = target.parentElement?.childNodes[0] as HTMLElement;
+  curSliceImagesWithTrackItem = sliceImageItem;
+
+  cacheTextTrack.x = e.offsetX;
+  cacheTextTrack.y = e.offsetY;
+  cacheTextTrack.id = randomCode();
+  cacheTextTrack.content = "";
+
+  void nextTick(() => {
+    const element = document.getElementById(
+      `text-edit-box-${cacheTextTrack.id}`
+    );
+    element?.focus();
+  });
+}
+
+function textTrackInput(e: Event) {
+  const target = e.target as HTMLElement;
+  cacheTextTrack.content = target.outerText;
+}
+function initCacheTextTrack() {
+  cacheTextTrack = {
+    x: 0,
+    y: 0,
+    maxW: 0,
+    maxY: 0,
+    content: "",
+    id: "",
+  };
+}
+function textTrackBlur() {
+  if (!cacheTextTrack.content) {
+    store.currentSpecialTagType = undefined;
+    initCacheTextTrack();
+    return;
+  }
+  const textBoxDom = document.getElementById(
+    `text-edit-box-${cacheTextTrack.id}`
+  );
+
+  // 减去内边距所占宽高
+  const tagName = JSON.stringify({
+    width: textBoxDom.offsetWidth - 10,
+    height: textBoxDom.offsetHeight - 10,
+    content: cacheTextTrack.content,
+  });
+
+  const track: SpecialTag = {
+    tagName,
+    tagType: store.currentSpecialTagType,
+    offsetIndex: curSliceImagesWithTrackItem.indexInSliceUrls,
+    offsetX:
+      cacheTextTrack.x * (curImageTarget.naturalWidth / curImageTarget.width) +
+      curSliceImagesWithTrackItem.dx,
+    offsetY:
+      cacheTextTrack.y *
+        (curImageTarget.naturalHeight / curImageTarget.height) +
+      curSliceImagesWithTrackItem.dy,
+  };
+
+  store.currentTaskEnsured.markResult.specialTagList.push(track);
+  curSliceImagesWithTrackItem.tagList.push(track);
+  initCacheTextTrack();
+}
+
+watch(
+  () => store.currentSpecialTagType,
+  () => {
+    if (cacheTextTrack.id) {
+      initCacheTextTrack();
+    }
+  }
+);
+</script>
+
+<style scoped>
+.image-canvas {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 9;
+}
+.text-edit-box {
+  position: absolute;
+  border: 1px solid #ff0000;
+  line-height: 24px;
+  padding: 5px;
+  font-size: 20px;
+  border-radius: 4px;
+  margin: -15px 0 0 -5px;
+  outline: none;
+  z-index: 9;
+  font-family: 黑体, arial, sans-serif;
+  color: #ff0000;
+}
+.text-edit-box:focus {
+  border-color: #ff5050;
+}
+</style>

+ 128 - 1
src/features/mark/MarkDrawTrack.vue

@@ -18,7 +18,24 @@
     </template>
   </transition-group>
   <template v-for="(tag, index) in specialTagList" :key="index">
-    <div class="score-container" :style="computeTopAndLeft(tag)">
+    <img
+      v-if="tag.tagType === 'TEXT'"
+      class="special-text"
+      :style="computeSpecialTextStyle(tag)"
+      :src="getSpecialTextImg(tag)"
+    />
+    <div
+      v-else-if="tag.tagType === 'LINE'"
+      class="special-line"
+      :style="computeSpecialLineStyle(tag)"
+    ></div>
+    <div
+      v-else-if="tag.tagType === 'CIRCLE'"
+      class="special-circle"
+      :style="computeSpecialCircleStyle(tag)"
+      @click="circleTagClickHandle"
+    ></div>
+    <div v-else class="score-container" :style="computeTopAndLeft(tag)">
       <span class="tw-m-auto">
         {{ tag.tagName }}
       </span>
@@ -47,6 +64,116 @@ const props = defineProps<{
 }>();
 const { trackList } = toRefs(props);
 
+const computeSpecialLineStyle = (track: SpecialTag) => {
+  // {"tagName":"{\"len\":241.9842519685039}","tagType":"LINE","offsetIndex":2,"offsetX":324.8193228048039,"offsetY":759.8560783391572,"positionX":0.06189180773871382,"positionY":-0.01054037309709878}
+  const tagProp = JSON.parse(track.tagName);
+
+  const topInsideSlice = track.offsetY - props.dy;
+  const leftInsideSlice = track.offsetX - props.dx;
+  const topInsideSliceRatio = topInsideSlice / props.sliceImageHeight;
+  const leftInsideSliceRatio = leftInsideSlice / props.sliceImageWidth;
+  return {
+    top: topInsideSliceRatio * 100 + "%",
+    left: leftInsideSliceRatio * 100 + "%",
+    width: (100 * tagProp.len) / props.sliceImageWidth + "%",
+    position: "absolute",
+    borderTop: `1px solid ${track.color || "red"}`,
+    zIndex: 9,
+  };
+};
+const computeSpecialCircleStyle = (track: SpecialTag) => {
+  // {"tagName":"{\"len\":241.9842519685039}","tagType":"LINE","offsetIndex":2,"offsetX":324.8193228048039,"offsetY":759.8560783391572,"positionX":0.06189180773871382,"positionY":-0.01054037309709878}
+  const tagProp = JSON.parse(track.tagName);
+
+  const topInsideSlice = track.offsetY - props.dy;
+  const leftInsideSlice = track.offsetX - props.dx;
+  const topInsideSliceRatio = topInsideSlice / props.sliceImageHeight;
+  const leftInsideSliceRatio = leftInsideSlice / props.sliceImageWidth;
+  return {
+    top: topInsideSliceRatio * 100 + "%",
+    left: leftInsideSliceRatio * 100 + "%",
+    width: (100 * tagProp.width) / props.sliceImageWidth + "%",
+    height: (100 * tagProp.height) / props.sliceImageHeight + "%",
+    position: "absolute",
+    border: `1px solid ${track.color || "red"}`,
+    borderRadius: "50%",
+    zIndex: 9,
+  };
+};
+
+const computeSpecialTextStyle = (track: SpecialTag) => {
+  // {"tagName":"{\"len\":241.9842519685039}","tagType":"LINE","offsetIndex":2,"offsetX":324.8193228048039,"offsetY":759.8560783391572,"positionX":0.06189180773871382,"positionY":-0.01054037309709878}
+  const tagProp = JSON.parse(track.tagName);
+
+  const topInsideSlice = track.offsetY - props.dy;
+  const leftInsideSlice = track.offsetX - props.dx;
+  const topInsideSliceRatio = topInsideSlice / props.sliceImageHeight;
+  const leftInsideSliceRatio = leftInsideSlice / props.sliceImageWidth;
+
+  return {
+    top: topInsideSliceRatio * 100 + "%",
+    left: leftInsideSliceRatio * 100 + "%",
+    width: (1.3 * 100 * tagProp.width) / props.sliceImageWidth + "%",
+    height: (1.3 * 100 * tagProp.height) / props.sliceImageHeight + "%",
+    position: "absolute",
+    marginTop: "-10px",
+    zIndex: 9,
+  };
+};
+const getSpecialTextImg = (track: SpecialTag) => {
+  const tagProp = JSON.parse(track.tagName);
+  const canvas = document.createElement("canvas");
+  canvas.width = tagProp.width * 2;
+  canvas.height = tagProp.height * 2;
+  const ctx = canvas.getContext("2d");
+
+  ctx.fillStyle = track.color || "#ff0000";
+  ctx.font = "normal 40px 黑体";
+
+  const contents = tagProp.content.split("\n");
+  const lineHeight = 48;
+  let y = 42;
+  const x = 0;
+
+  // 每次回车换行时会多一个换行符,需要剔除
+  let conts = [];
+  let lastContIsEmpty = false;
+  contents.forEach((cont) => {
+    if (cont.trim()) {
+      conts.push(cont);
+      lastContIsEmpty = false;
+    } else {
+      if (lastContIsEmpty) return;
+      conts.push(cont);
+      lastContIsEmpty = true;
+    }
+  });
+
+  conts.forEach((cont) => {
+    if (!cont) {
+      y += lineHeight;
+      return;
+    }
+    let arrText = cont.split("");
+    let line = "";
+    for (let n = 0; n < arrText.length; n++) {
+      let textLine = line + arrText[n];
+      const metrics = ctx.measureText(textLine);
+      if (metrics.width > canvas.width && n > 0) {
+        ctx.fillText(line, x, y);
+        line = arrText[n];
+        y += lineHeight;
+      } else {
+        line = textLine;
+      }
+    }
+    ctx.fillText(line, x, y);
+    y += lineHeight;
+  });
+
+  return canvas.toDataURL();
+};
+
 const computeTopAndLeft = (track: Track | SpecialTag) => {
   const topInsideSlice = track.offsetY - props.dy;
   const leftInsideSlice = track.offsetX - props.dx;

+ 24 - 2
src/features/mark/SpecialTagModal.vue

@@ -30,13 +30,26 @@
       >
       </div>
+      <div
+        :class="[store.currentSpecialTag === 'O' && 'tag-selected', 'tag']"
+        @click="toggleTag('CIRCLE')"
+      >
+        O
+      </div>
       <div
         :class="[store.currentSpecialTag === '——' && 'tag-selected', 'tag']"
         style="width: 70px; border-radius: 5px"
-        @click="toggleTag('——')"
+        @click="toggleTag('LINE')"
       >
         <u>下划线</u>
       </div>
+      <div
+        :class="[store.currentSpecialTag === 'text' && 'tag-selected', 'tag']"
+        style="width: 70px; border-radius: 5px"
+        @click="toggleTag('TEXT')"
+      >
+        <u>文字</u>
+      </div>
     </div>
 
     <div class="tw-flex tw-place-content-between tw-mt-8 tw-mx-2">
@@ -70,6 +83,8 @@
 <script setup lang="ts">
 import { store } from "@/store/store";
 
+const customSpecialTagTypes = ["CIRCLE", "LINE", "TEXT"];
+
 function clearLatestTagOfCurrentTask() {
   if (!store.currentTask?.markResult) return;
   store.currentTask.markResult.specialTagList.splice(-1);
@@ -83,9 +98,16 @@ function clearAllTagsOfCurrentTask() {
 const toggleTag = (tagName: string) => {
   if (store.currentSpecialTag === tagName) {
     store.currentSpecialTag = undefined;
+    store.currentSpecialTagType = undefined;
   } else {
-    store.currentSpecialTag = tagName;
     store.currentScore = undefined;
+    if (customSpecialTagTypes.includes(tagName)) {
+      store.currentSpecialTagType = tagName;
+      store.currentSpecialTag = "";
+    } else {
+      store.currentSpecialTagType = "TAG";
+      store.currentSpecialTag = tagName;
+    }
   }
 };
 

+ 2 - 0
src/types/index.ts

@@ -40,6 +40,7 @@ export interface MarkStore {
   currentQuestion?: Question;
   currentScore?: number;
   currentSpecialTag?: string;
+  currentSpecialTagType?: SpecialTag["tagType"];
   /** 是否打开回评侧边栏 */
   historyOpen: boolean;
   historyTasks: Array<Task>;
@@ -320,6 +321,7 @@ export interface SpecialTag {
   positionY: number;
   /** 特殊标记的字符串,勾叉 */
   tagName: string;
+  tagType: "TEXT" | "TAG";
   markerId?: number;
   color?: string;
   isByMultMark?: boolean;

+ 24 - 0
src/utils/utils.ts

@@ -355,3 +355,27 @@ export function addHeaderTrackColorAttr(headerTrack: any): any {
     return item;
   });
 }
+
+/**
+ * 获取随机code,默认获取16位
+ * @param {Number} len 推荐8的倍数
+ *
+ */
+export function randomCode(len = 16): string {
+  if (len <= 0) return;
+  const steps = Math.ceil(len / 8);
+  const stepNums = [];
+  for (let i = 0; i < steps; i++) {
+    const ranNum = Math.random().toString(32).slice(-8);
+    stepNums.push(ranNum);
+  }
+
+  return stepNums.join("");
+}
+
+export function getTrackId(data: {
+  mainNumber: number;
+  subNumber: number;
+}): string {
+  return `${data.mainNumber}-${data.subNumber}-${randomCode()}`;
+}