Jelajahi Sumber

构建snap系统

bill 8 bulan lalu
induk
melakukan
8acb54efbc
42 mengubah file dengan 2205 tambahan dan 1320 penghapusan
  1. TEMPAT SAMPAH
      public/WX20241213-205427@2x.png
  2. 37 18
      src/core/components/arrow/arrow.vue
  3. 17 11
      src/core/components/arrow/index.ts
  4. 27 4
      src/core/components/arrow/temp-arrow.vue
  5. 44 41
      src/core/components/circle/circle.vue
  6. 33 15
      src/core/components/circle/index.ts
  7. 18 3
      src/core/components/circle/temp-circle.vue
  8. 28 21
      src/core/components/icon/icon.vue
  9. 44 18
      src/core/components/icon/index.ts
  10. 62 58
      src/core/components/icon/temp-icon.vue
  11. 27 23
      src/core/components/image/image.vue
  12. 30 17
      src/core/components/image/index.ts
  13. 48 48
      src/core/components/image/temp-image.vue
  14. 16 2
      src/core/components/index.ts
  15. 14 24
      src/core/components/line/index.ts
  16. 28 19
      src/core/components/line/line.vue
  17. 34 4
      src/core/components/line/temp-line.vue
  18. 15 20
      src/core/components/polygon/index.ts
  19. 28 19
      src/core/components/polygon/polygon.vue
  20. 24 4
      src/core/components/polygon/temp-polygon.vue
  21. 3 11
      src/core/components/rectangle/index.ts
  22. 15 76
      src/core/components/rectangle/rectangle.vue
  23. 13 20
      src/core/components/triangle/index.ts
  24. 24 4
      src/core/components/triangle/temp-triangle.vue
  25. 28 19
      src/core/components/triangle/triangle.vue
  26. 64 7
      src/core/components/util.ts
  27. 1 1
      src/core/helper/active-boxs.vue
  28. 63 44
      src/core/helper/snap-lines.vue
  29. 1 1
      src/core/hook/use-automatic-data.ts
  30. 145 0
      src/core/hook/use-component.ts
  31. 2 1
      src/core/hook/use-copy.ts
  32. 456 280
      src/core/hook/use-snap.ts
  33. 219 86
      src/core/hook/use-transformer.ts
  34. 41 1
      src/core/hook/use-viewer.ts
  35. 21 10
      src/core/propertys/util.ts
  36. 1 1
      src/core/renderer/draw-group.vue
  37. 4 3
      src/core/renderer/group.vue
  38. 164 163
      src/core/store/init.ts
  39. 62 0
      src/core/transformer.ts
  40. 93 44
      src/utils/math.ts
  41. 89 74
      src/utils/resource.ts
  42. 122 105
      src/utils/shared.ts

TEMPAT SAMPAH
public/WX20241213-205427@2x.png


+ 37 - 18
src/core/components/arrow/arrow.vue

@@ -1,26 +1,45 @@
 <template>
-  <v-arrow :config="dataToConfig(data)" ref="shape" />
+  <TempArrow :data="tData" :ref="(v: any) => shape = v?.shape" :id="data.id" />
+  <PropertyUpdate
+    :describes="describes"
+    :data="data"
+    :target="shape"
+    @change="emit('updateShape', { ...data })"
+  />
+  <Operate :target="shape" :menus="operateMenus" />
 </template>
 
 <script lang="ts" setup>
-import { ArrowData, dataToConfig, style } from "./index.ts";
-import { useMouseStyle } from "../../hook/use-mouse-status.ts";
-import { ref } from "vue";
-import { useLineTransformer } from "../../hook/use-transformer.ts";
-import { DC } from "@/deconstruction.js";
-import { useAniamtion } from "../../hook/use-animation.ts";
 import { Arrow } from "konva/lib/shapes/Arrow";
+import { PropertyUpdate, Operate } from "../../propertys";
+import { ArrowData, getMouseStyle, defaultStyle } from "./index.ts";
+import TempArrow from "./temp-arrow.vue";
+import { useComponentStatus } from "@/core/hook/use-component.ts";
+import { Line } from "konva/lib/shapes/Line";
 
 const props = defineProps<{ data: ArrowData }>();
-const emit = defineEmits<{ (e: "update", value: ArrowData): void }>();
-const shape = ref<DC<Arrow>>();
-useLineTransformer(
-  shape,
-  () => props.data,
-  (newData) => {
-    emit("update", { ...props.data, ...newData });
-  }
-);
-const { currentStyle } = useMouseStyle({ style, shape });
-useAniamtion(currentStyle, shape);
+const emit = defineEmits<{
+  (e: "updateShape", value: ArrowData): void;
+  (e: "addShape", value: ArrowData): void;
+  (e: "delShape"): void;
+}>();
+
+const { shape, tData, operateMenus, describes } = useComponentStatus<Arrow, ArrowData>({
+  emit,
+  props,
+  getMouseStyle,
+  defaultStyle,
+  getRepShape(): Line {
+    return new Line({
+      fill: "rgb(0, 255, 0)",
+      visible: false,
+      strokeWidth: 0,
+      points: shape.value!.getNode().points(),
+    });
+  },
+  copyHandler(tf, data) {
+    data.points = data.points.map((v) => tf.point(v));
+    return data;
+  },
+});
 </script>

+ 17 - 11
src/core/components/arrow/index.ts

@@ -3,7 +3,8 @@ import { flatPositions, onlyId } from "@/utils/shared.ts";
 import { InteractiveMessage } from "../../hook/use-interactive.ts";
 import { ArrowConfig } from "konva/lib/shapes/Arrow";
 import { themeMouseColors } from "@/constant/help-style.ts";
-import { BaseItem, getBaseItem } from "../util.ts";
+import { BaseItem, generateSnapInfos, getBaseItem } from "../util.ts";
+import { getMouseColors } from "@/utils/colors.ts";
 
 export { default as Component } from "./arrow.vue";
 export { default as TempComponent } from "./temp-arrow.vue";
@@ -17,18 +18,19 @@ export const defaultStyle = {
 
 export const addMode = "area";
 
-export const style = {
-  default: defaultStyle,
-  focus: {
-    stroke: themeMouseColors.hover,
-    fill: themeMouseColors.hover,
-  },
-  hover: {
-    stroke: themeMouseColors.theme,
-    fill: themeMouseColors.hover,
-  },
+export const getMouseStyle = (data: ArrowData) => {
+  const fillStatus = getMouseColors(data.fill || defaultStyle.fill);
+  const strokeStatus = getMouseColors(data.stroke || defaultStyle.stroke);
+  const strokeWidth = data.strokeWidth || defaultStyle.strokeWidth;
+
+  return {
+    default: { fill: fillStatus.pub, stroke: strokeStatus.pub, strokeWidth },
+    hover: { fill: fillStatus.hover },
+    press: { fill: fillStatus.press },
+  };
 };
 
+
 export type ArrowData = Partial<typeof defaultStyle> &
   BaseItem & {
     points: Pos[];
@@ -42,6 +44,10 @@ export const dataToConfig = (data: ArrowData): ArrowConfig => ({
   hitStrokeWidth: 20,
 });
 
+export const getSnapInfos = (data: ArrowData) => {
+  return generateSnapInfos(data.points, true, false)
+}
+
 export const interactiveToData = (
   info: InteractiveMessage,
   preset: Partial<ArrowData> = {}

+ 27 - 4
src/core/components/arrow/temp-arrow.vue

@@ -1,8 +1,31 @@
 <template>
-	<v-arrow :config="{...dataToConfig(data), opacity: 0.3}" />
+  <v-arrow
+    :config="{
+      ...data,
+      zIndex: undefined,
+      hitStrokeWidth: 20,
+      pointerLength: 10,
+      pointerWidth: 10,
+      closed: true,
+      points: flatPositions(data.points),
+      opacity: addMode ? 0.3 : 1,
+    }"
+    ref="shape"
+  />
 </template>
 
 <script lang="ts" setup>
-import { ArrowData, dataToConfig } from "./index.ts";
-defineProps<{ data: ArrowData }>()
-</script>
+import { ArrowData } from "./index.ts";
+import { DC } from "@/deconstruction.js";
+import { ref } from "vue";
+import { flatPositions } from "@/utils/shared.ts";
+import { Arrow } from "konva/lib/shapes/Arrow";
+defineProps<{ data: ArrowData; addMode?: boolean }>();
+
+const shape = ref<DC<Arrow>>();
+defineExpose({
+  get shape() {
+    return shape.value;
+  },
+});
+</script>

+ 44 - 41
src/core/components/circle/circle.vue

@@ -1,50 +1,53 @@
 <template>
-  <v-circle :config="dataToConfig(atData)" ref="shape"> </v-circle>
+  <TempCircle :data="tData" :ref="(v: any) => shape = v?.shape" :id="data.id" />
+  <PropertyUpdate
+    :describes="describes"
+    :data="data"
+    :target="shape"
+    @change="emit('updateShape', { ...data })"
+  />
+  <Operate :target="shape" :menus="operateMenus" />
 </template>
 
 <script lang="ts" setup>
-import { Circle } from "konva/lib/shapes/Circle";
-import { useShapeTransformer, useTransformer } from "../../hook/use-transformer.ts";
-import { CircleData, dataToConfig, style } from "./index.ts";
-import { DC } from "@/deconstruction.js";
-import { ref, watch } from "vue";
-import { useMouseStyle } from "../../hook/use-mouse-status.ts";
-import { useAniamtion } from "../../hook/use-animation.ts";
-import { useAutomaticData } from "../../hook/use-automatic-data.ts";
-import { setShapeTransform } from "@/utils/shape.ts";
-import { Transform } from "konva/lib/Util";
+import { CircleData, getMouseStyle, defaultStyle } from "./index.ts";
+import { PropertyUpdate, Operate } from "../../propertys";
+import TempCircle from "./temp-circle.vue";
+import { useComponentStatus } from "@/core/hook/use-component.ts";
 
 const props = defineProps<{ data: CircleData }>();
-const emit = defineEmits<{ (e: "update", value: CircleData): void }>();
-const shape = ref<DC<Circle>>();
-const transform = useShapeTransformer(shape, {
-  rotateEnabled: false,
-  keepRatio: true,
-  enabledAnchors: ["top-left", "top-right", "bottom-left", "bottom-right"],
-});
-const transformer = useTransformer();
-
-const atData = useAutomaticData(() => props.data);
+const emit = defineEmits<{
+  (e: "updateShape", value: CircleData): void;
+  (e: "addShape", value: CircleData): void;
+  (e: "delShape"): void;
+}>();
 
-watch(transform, (transform, oldTransform) => {
-  if (transform) {
-    const { x, y, scaleX } = transform.decompose();
-    if (scaleX !== 1) {
-      atData.value.radius *= scaleX;
-      setShapeTransform(
-        shape.value!.getNode(),
-        new Transform().translate(atData.value.x, atData.value.y)
-      );
-      transformer.forceUpdate();
-    } else {
-      atData.value.x = x;
-      atData.value.y = y;
-    }
-  } else if (oldTransform) {
-    emit("update", atData.value);
-  }
+const { shape, tData, data, operateMenus, describes } = useComponentStatus({
+  emit,
+  props,
+  getMouseStyle,
+  defaultStyle,
+  copyHandler(tf, data) {
+    data.x = tf.multiply(new Transform(data.mat)).m;
+    return data;
+  },
 });
-
-const { currentStyle } = useMouseStyle({ style, shape });
-useAniamtion(currentStyle, shape);
+// watch(transform, (transform, oldTransform) => {
+//   if (transform) {
+//     const { x, y, scaleX } = transform.decompose();
+//     if (scaleX !== 1) {
+//       atData.value.radius *= scaleX;
+//       setShapeTransform(
+//         shape.value!.getNode(),
+//         new Transform().translate(atData.value.x, atData.value.y)
+//       );
+//       transformer.forceUpdate();
+//     } else {
+//       atData.value.x = x;
+//       atData.value.y = y;
+//     }
+//   } else if (oldTransform) {
+//     emit("update", atData.value);
+//   }
+// });
 </script>

+ 33 - 15
src/core/components/circle/index.ts

@@ -1,7 +1,14 @@
 import { InteractiveMessage } from "../../hook/use-interactive.ts";
 import { CircleConfig } from "konva/lib/shapes/Circle";
 import { themeMouseColors } from "@/constant/help-style.ts";
-import { BaseItem, getBaseItem } from "../util.ts";
+import {
+  BaseItem,
+  generateSnapInfos,
+  getBaseItem,
+  getRectSnapPoints,
+} from "../util.ts";
+import { getMouseColors } from "@/utils/colors.ts";
+import { vector } from "@/utils/math.ts";
 
 export { default as Component } from "./circle.vue";
 export { default as TempComponent } from "./temp-circle.vue";
@@ -13,25 +20,36 @@ export const defaultStyle = {
   strokeWidth: 1,
 };
 
-export const addMode = 'area'
+export const addMode = "area";
 
-export const style = {
-  default: defaultStyle,
-  focus: {
-    fill: themeMouseColors.theme,
-    stroke: themeMouseColors.hover,
-  },
-  hover: {
-    stroke: themeMouseColors.theme,
-  }
+export const getMouseStyle = (data: CircleData) => {
+  const fillStatus = getMouseColors(data.fill || defaultStyle.fill);
+  const strokeStatus = getMouseColors(data.stroke || defaultStyle.stroke);
+  const strokeWidth = data.strokeWidth || defaultStyle.strokeWidth;
+
+  return {
+    default: { fill: fillStatus.pub, stroke: strokeStatus.pub, strokeWidth },
+    hover: { fill: fillStatus.hover },
+    press: { fill: fillStatus.press },
+  };
 };
 
-export type CircleData = Partial<typeof defaultStyle> & BaseItem & {
-  x: number;
-  y: number;
-  radius: number;
+export const getSnapInfos = (data: CircleData) => {
+  const size = data.radius * 2;
+  const points = getRectSnapPoints(size, size).map((v) => ({
+    x: v.x + data.x,
+    y: v.y + data.y,
+  }));
+  return generateSnapInfos(points, true, false);
 };
 
+export type CircleData = Partial<typeof defaultStyle> &
+  BaseItem & {
+    x: number;
+    y: number;
+    radius: number;
+  };
+
 export const dataToConfig = (data: CircleData): CircleConfig => ({
   ...defaultStyle,
   ...data,

+ 18 - 3
src/core/components/circle/temp-circle.vue

@@ -1,9 +1,24 @@
 <template>
-	<v-circle :config="dataToConfig(data)">
+	<v-circle 
+    :config="{
+      ...data,
+      zIndex: undefined,
+      opacity: addMode ? 0.3 : 1,
+    }">
 	</v-circle>
 </template>
 
 <script lang="ts" setup>
-import { CircleData, dataToConfig } from "./index.ts";
-defineProps<{ data: CircleData }>()
+import { CircleData } from "./index.ts";
+import { ref } from "vue";
+import { DC } from "@/deconstruction.js";
+import { Circle } from "konva/lib/shapes/Circle";
+defineProps<{ data: CircleData; addMode?: boolean }>();
+
+const shape = ref<DC<Circle>>();
+defineExpose({
+  get shape() {
+    return shape.value;
+  },
+});
 </script>

+ 28 - 21
src/core/components/icon/icon.vue

@@ -1,30 +1,37 @@
 <template>
-  <TempIcon :data="{ ...styleData.data, ...data }" :ref="(e: any) => shape = e.shape" />
+  <TempIcon :data="tData" :ref="(e: any) => shape = e.shape" />
+  <PropertyUpdate
+    :describes="describes"
+    :data="data"
+    :target="shape"
+    @change="emit('updateShape', { ...data })"
+  />
+  <Operate :target="shape" :menus="operateMenus" />
 </template>
 
 <script lang="ts" setup>
 import TempIcon from "./temp-icon.vue";
-import { IconData, style } from "./index.ts";
-import { ref, watch } from "vue";
-import { Group } from "konva/lib/Group";
-import { DC } from "@/deconstruction.js";
-import { useShapeTransformer } from "../../hook/use-transformer.ts";
-import { useAutomaticData } from "../../hook/use-automatic-data.ts";
-import { useMouseStyle } from "../../hook/use-mouse-status.ts";
-import { useAniamtion } from "../../hook/use-animation.ts";
+import { IconData, getMouseStyle, defaultStyle } from "./index.ts";
+import { useComponentStatus } from "@/core/hook/use-component.ts";
+import { PropertyUpdate, Operate } from "../../propertys";
+import { Transform } from "konva/lib/Util";
 
-const props = defineProps<{ data: IconData; addMode?: boolean }>();
-const emit = defineEmits<{ (e: "update", value: IconData): void }>();
-const shape = ref<DC<Group>>();
-const transform = useShapeTransformer(shape);
-const atData = useAutomaticData(() => props.data);
+const props = defineProps<{ data: IconData }>();
+const emit = defineEmits<{
+  (e: "updateShape", value: IconData): void;
+  (e: "addShape", value: IconData): void;
+  (e: "delShape"): void;
+}>();
 
-watch(transform, (transform, oldTransform) => {
-  if (!transform && oldTransform) {
-    emit("update", { ...atData.value, mat: oldTransform.m });
-  }
+const { shape, tData, data, operateMenus, describes } = useComponentStatus({
+  emit,
+  props,
+  getMouseStyle,
+  line: false,
+  defaultStyle,
+  copyHandler(tf, data) {
+    data.mat = tf.multiply(new Transform(data.mat)).m;
+    return data;
+  },
 });
-
-const { currentStyle } = useMouseStyle({ style, shape });
-const styleData = useAniamtion(currentStyle);
 </script>

+ 44 - 18
src/core/components/icon/index.ts

@@ -1,21 +1,44 @@
 import { InteractiveMessage } from "../../hook/use-interactive.ts";
 import { themeMouseColors } from "@/constant/help-style.ts";
 import { Transform } from "konva/lib/Util";
-import { BaseItem, getBaseItem } from "../util.ts";
+import { BaseItem, generateSnapInfos, getBaseItem, getRectSnapPoints } from "../util.ts";
+import { getMouseColors } from "@/utils/colors.ts";
 
 export { default as Component } from "./icon.vue";
 export { default as TempComponent } from "./temp-icon.vue";
 
 export const shapeName = "图例";
 export const defaultStyle = {
-  coverFill: '#fff',
+  coverFill: themeMouseColors.theme,
   coverOpcatiy: 0,
   // strokeScaleEnabled: true,
   width: 80,
   height: 80,
 };
 
-export const addMode = 'dot'
+export const addMode = "dot";
+
+export const getSnapInfos = (data: IconData) => {
+  const tf = new Transform(data.mat);
+  const w = data.width || defaultStyle.width;
+  const h = data.height || defaultStyle.height;
+  const points = getRectSnapPoints(w, h)
+  return generateSnapInfos(
+    points.map((v) => tf.point(v)),
+    true,
+    false
+  );
+};
+
+export const getMouseStyle = (data: IconData) => {
+  const fillStatus = getMouseColors(data.coverFill || defaultStyle.coverFill);
+
+  return {
+    default: { coverFill: fillStatus.pub, coverOpcatiy: 0 },
+    hover: { coverFill: fillStatus.hover, coverOpcatiy: 0.3 },
+    press: { coverFill: fillStatus.press, coverOpcatiy: 0.3 },
+  };
+};
 
 export const style = {
   default: defaultStyle,
@@ -33,30 +56,33 @@ export const style = {
   },
 };
 
-export type IconData = Partial<typeof defaultStyle> & BaseItem & {
-  coverFill?: string;
-  coverStroke?: string,
-  coverStrokeWidth?: number,
-  width: number;
-  height: number;
-  mat: number[];
-  url: string
-};
-
+export type IconData = Partial<typeof defaultStyle> &
+  BaseItem & {
+    coverFill?: string;
+    coverStroke?: string;
+    coverStrokeWidth?: number;
+    width: number;
+    height: number;
+    mat: number[];
+    url: string;
+  };
 
 export const dataToConfig = (data: IconData) => {
   return {
     ...defaultStyle,
     ...data,
-  }
-}
+  };
+};
 
 export const interactiveToData = (
   info: InteractiveMessage,
   preset: Partial<IconData> = {}
 ): IconData | undefined => {
   if (info.dot) {
-    return interactiveFixData({ ...getBaseItem(), ...preset } as unknown as IconData, info);
+    return interactiveFixData(
+      { ...getBaseItem(), ...preset } as unknown as IconData,
+      info
+    );
   }
 };
 
@@ -64,7 +90,7 @@ export const interactiveFixData = (
   data: IconData,
   info: InteractiveMessage
 ) => {
-  const mat = new Transform().translate(info.dot!.x, info.dot!.y)
-  data.mat = mat.m
+  const mat = new Transform().translate(info.dot!.x, info.dot!.y);
+  data.mat = mat.m;
   return data;
 };

+ 62 - 58
src/core/components/icon/temp-icon.vue

@@ -1,81 +1,85 @@
 <template>
-	<v-group :config="groupConfig" v-if="groupConfig" ref="shape">
-		<v-group :config="initDecMat" >
-			<v-rect :config="rectConfig" />
-			<v-path v-for="config in pathConfigs" :config="config"/>
-		</v-group>
-	</v-group>
+  <v-group :config="groupConfig" v-if="groupConfig" ref="shape">
+    <v-group :config="initDecMat">
+      <v-rect :config="rectConfig" />
+      <v-path v-for="config in pathConfigs" :config="config" />
+    </v-group>
+  </v-group>
 </template>
 
 <script lang="ts" setup>
-import { IconData, dataToConfig } from "./index.ts";
+import { IconData } from "./index.ts";
 import { computed, ref, watch } from "vue";
 import { getSvgContent, parseSvgContent, SVGParseResult } from "@/utils/resource.ts";
 import { Group } from "konva/lib/Group";
 import { DC } from "@/deconstruction.js";
 import { Transform } from "konva/lib/Util";
 
-const props = defineProps<{ data: IconData, addMode?: boolean }>()
-const svg = ref<SVGParseResult | null>(null)
-const data = computed(() => dataToConfig(props.data))
-const shape = ref<DC<Group>>()
+const props = defineProps<{ data: IconData; addMode?: boolean }>();
+const svg = ref<SVGParseResult | null>(null);
+const shape = ref<DC<Group>>();
 
-defineExpose({ 
+defineExpose({
   get shape() {
-    return shape.value
-  } 
-})
+    return shape.value;
+  },
+});
 
-watch(() => data.value.url, async url => {
-	svg.value = null;
-	const svgContent = await getSvgContent(url)
-	svg.value = parseSvgContent(svgContent)
-}, {immediate: true})
+watch(
+  () => props.data.url,
+  async (url) => {
+    svg.value = null;
+    const svgContent = await getSvgContent(url);
+    svg.value = parseSvgContent(svgContent);
+  },
+  { immediate: true }
+);
 
 const pathConfigs = computed(() => {
-	if (!svg.value) return [];
-	return svg.value.paths.map(path => ({
-		...path,
-		...data.value,
-		offset: {x: svg.value!.x, y: svg.value!.y}
-	}))
-})
+  if (!svg.value) return [];
+  return svg.value.paths.map((path) => ({
+    ...path,
+    ...props.data,
+    zIndex: undefined,
+    offset: { x: svg.value!.x, y: svg.value!.y },
+  }));
+});
 
 const initDecMat = computed(() => {
-	if (!svg.value) return null;
-	let w = data.value.width
-	let h = data.value.height
-	w = w || svg.value.width || 0
-	h = h || svg.value.height || 0
+  if (!svg.value) return null;
+  let w = props.data.width;
+  let h = props.data.height;
+  w = w || svg.value.width || 0;
+  h = h || svg.value.height || 0;
 
-	const scale = {
-		x: w / svg.value.width,
-		y: h / svg.value.height,
-	}
+  const scale = {
+    x: w / svg.value.width,
+    y: h / svg.value.height,
+  };
 
-	return new Transform()
-		.scale(scale.x, scale.y)
-		.multiply(new Transform().translate(-svg.value.width / 2, -svg.value.height / 2))
-		.decompose()
-})
+  return new Transform()
+    .scale(scale.x, scale.y)
+    .multiply(new Transform().translate(-svg.value.width / 2, -svg.value.height / 2))
+    .decompose();
+});
 
 const groupConfig = computed(() => {
-	return {
-		...new Transform(data.value.mat).decompose(),
-		opacity: props.addMode ? 0.3 : 1,
-	}
-})
+  return {
+    ...new Transform(props.data.mat).decompose(),
+    opacity: props.addMode ? 0.3 : 1,
+  };
+});
 
 const rectConfig = computed(() => {
-	if (!svg.value) return null;
-	return {
-		fill: data.value.coverFill,
-		id: 'rep',
-		stroke: data.value.coverStroke,
-		opacity: data.value.coverOpcatiy,
-		strokeWidth: data.value.coverStrokeWidth,
-		width: svg.value.width,
-		height: svg.value.height,
-	}
-})
-</script>
+  if (!svg.value) return null;
+  return {
+    fill: props.data.coverFill,
+    id: "rep",
+    stroke: props.data.coverStroke,
+    opacity: props.data.coverOpcatiy,
+    strokeWidth: props.data.coverStrokeWidth,
+    width: svg.value.width,
+    height: svg.value.height,
+  };
+});
+</script>

+ 27 - 23
src/core/components/image/image.vue

@@ -1,33 +1,37 @@
 <template>
-  <TempImage
-    :data="{ ...props.data, ...animation.data }"
-    :ref="(e: any) => shape = e.shape"
+  <TempImage :data="tData" :ref="(e: any) => shape = e.shape" />
+  <PropertyUpdate
+    :describes="describes"
+    :data="data"
+    :target="shape"
+    @change="emit('updateShape', { ...data })"
   />
+  <Operate :target="shape" :menus="operateMenus" />
 </template>
 
 <script lang="ts" setup>
 import TempImage from "./temp-image.vue";
-import { ImageData, style } from "./index.ts";
-import { ref, watch } from "vue";
-import { Group } from "konva/lib/Group";
-import { DC } from "@/deconstruction.js";
-import { useShapeTransformer } from "../../hook/use-transformer.ts";
-import { useAutomaticData } from "../../hook/use-automatic-data.ts";
-import { useMouseStyle } from "../../hook/use-mouse-status.ts";
-import { useAniamtion } from "../../hook/use-animation.ts";
+import { ImageData, getMouseStyle, defaultStyle } from "./index.ts";
+import { useComponentStatus } from "@/core/hook/use-component.ts";
+import { PropertyUpdate, Operate } from "../../propertys";
+import { Transform } from "konva/lib/Util";
 
-const props = defineProps<{ data: ImageData; addMode?: boolean }>();
-const emit = defineEmits<{ (e: "update", value: ImageData): void }>();
-const shape = ref<DC<Group>>();
-const transform = useShapeTransformer(shape);
-const atData = useAutomaticData(() => props.data);
+const props = defineProps<{ data: ImageData }>();
+const emit = defineEmits<{
+  (e: "updateShape", value: ImageData): void;
+  (e: "addShape", value: ImageData): void;
+  (e: "delShape"): void;
+}>();
 
-watch(transform, (transform, oldTransform) => {
-  if (!transform && oldTransform) {
-    emit("update", { ...atData.value, mat: oldTransform.m });
-  }
+const { shape, tData, data, operateMenus, describes } = useComponentStatus({
+  emit,
+  props,
+  getMouseStyle,
+  line: false,
+  defaultStyle,
+  copyHandler(tf, data) {
+    data.mat = tf.multiply(new Transform(data.mat)).m;
+    return data;
+  },
 });
-
-const { currentStyle } = useMouseStyle({ style, shape });
-const animation = useAniamtion(currentStyle);
 </script>

+ 30 - 17
src/core/components/image/index.ts

@@ -1,7 +1,8 @@
-import { themeMouseColors } from "@/constant/help-style.ts";
 import { InteractiveMessage } from "../../hook/use-interactive.ts";
 import { Transform } from "konva/lib/Util";
-import { BaseItem, getBaseItem } from "../util.ts";
+import { BaseItem, generateSnapInfos, getBaseItem, getRectSnapPoints } from "../util.ts";
+import { getMouseColors } from "@/utils/colors.ts";
+import { imageInfo } from "@/utils/resource.ts";
 
 export { default as Component } from "./image.vue";
 export { default as TempComponent } from "./temp-image.vue";
@@ -14,18 +15,36 @@ export const defaultStyle = {
 
 export const addMode = 'dot'
 
-export const style = {
-  default: defaultStyle,
-  focus: {
-    strokeWidth: 4,
-    stroke: themeMouseColors.hover,
-  },
-  hover: {
-    strokeWidth: 4,
-    stroke: themeMouseColors.theme,
+export const getMouseStyle = (data: ImageData) => {
+  const strokeStatus = getMouseColors(data.stroke || defaultStyle.stroke);
+
+  return {
+    default: { stroke: strokeStatus.pub, strokeWidth: 0 },
+    hover: { stroke: strokeStatus.hover, strokeWidth: 4 },
+    press: { stroke: strokeStatus.press, strokeWidth: 4 },
+  };
+};
+
+
+export const getSnapInfos = (data: ImageData) => {
+  const tf = new Transform(data.mat);
+  const useData = data.width && data.height
+  if (!useData && !(data.url in imageInfo)) {
+    return []
   }
+  
+  const w = useData ?  data.width : imageInfo[data.url].width
+  const h = useData ?  data.height : imageInfo[data.url].height
+  const points = getRectSnapPoints(w, h)
+  
+  return generateSnapInfos(
+    points.map((v) => tf.point(v)),
+    true,
+    false
+  );
 };
 
+
 export type ImageData = Partial<typeof defaultStyle> & BaseItem & {
   fill?: string;
   stroke?: string;
@@ -37,12 +56,6 @@ export type ImageData = Partial<typeof defaultStyle> & BaseItem & {
   mat: number[]
 };
 
-
-export const dataToConfig = (data: ImageData) => ({
-  ...defaultStyle,
-  ...data
-})
-
 export const interactiveToData = (
   info: InteractiveMessage,
   preset: Partial<ImageData> = {}

+ 48 - 48
src/core/components/image/temp-image.vue

@@ -1,11 +1,11 @@
 <template>
-	<v-group :config="groupConfig" v-if="groupConfig" ref="shape">
-		<v-image :config="{...style, ...config}" v-if="image"/>
-	</v-group>
+  <v-group :config="groupConfig" v-if="groupConfig" ref="shape">
+    <v-image :config="{ ...data, ...config, zIndex: undefined }" v-if="image" />
+  </v-group>
 </template>
 
 <script lang="ts" setup>
-import { ImageData, dataToConfig } from "./index.ts";
+import { ImageData } from "./index.ts";
 import { computed, ref, watch } from "vue";
 import { getImage } from "@/utils/resource.ts";
 import { useResize } from "@/core/hook/use-event.ts";
@@ -13,56 +13,56 @@ import { Transform } from "konva/lib/Util";
 import { Group } from "konva/lib/Group";
 import { DC } from "@/deconstruction.js";
 
-const props = defineProps<{ data: ImageData, addMode?: boolean }>()
-const image = ref<HTMLImageElement | null>(null)
-const shape = ref<DC<Group>>()
+const props = defineProps<{ data: ImageData; addMode?: boolean }>();
+const image = ref<HTMLImageElement | null>(null);
+const shape = ref<DC<Group>>();
 
-defineExpose({ 
+defineExpose({
   get shape() {
-    return shape.value
-  } 
-})
+    return shape.value;
+  },
+});
 
-const style = computed(() => dataToConfig(props.data))
+watch(
+  () => props.data.url,
+  async (url) => {
+    image.value = null;
+    image.value = await getImage(url);
+  },
+  { immediate: true }
+);
 
-watch(() => props.data.url, async url => {
-	image.value = null;
-	image.value = await getImage(url)
-
-}, {immediate: true})
-
-const size = useResize()
+const size = useResize();
 const config = computed(() => {
-	let w = props.data.width
-	let h = props.data.height
-
-	// 认为是百分比
-	if (image.value && size.value && (w <= 1 || h <= 1)) {
-		w = w <= 1 ? size.value.width * w : w
-		h = h <= 1 ? size.value.height * h : h
-		w = w || (image.value.width / image.value.height) * h
-		h = h || (image.value.height / image.value.width) * w
-	}
-	w = w || image.value?.width || 0
-	h = h || image.value?.height || 0
+  let w = props.data.width;
+  let h = props.data.height;
 
+  // 认为是百分比
+  if (image.value && size.value && (w <= 1 || h <= 1)) {
+    w = w <= 1 ? size.value.width * w : w;
+    h = h <= 1 ? size.value.height * h : h;
+    w = w || (image.value.width / image.value.height) * h;
+    h = h || (image.value.height / image.value.width) * w;
+  }
+  w = w || image.value?.width || 0;
+  h = h || image.value?.height || 0;
 
-	return {
-		image: image.value,
-		opacity: props.addMode ? 0.3 : 1,
-		stroke: 'red',
-		width: w,
-		height: h,
-		offset: {
-			x: w / 2,
-			y: h / 2,
-		},
-	}
-})
+  return {
+    image: image.value,
+    opacity: props.addMode ? 0.3 : 1,
+    stroke: "red",
+    width: w,
+    height: h,
+    offset: {
+      x: w / 2,
+      y: h / 2,
+    },
+  };
+});
 
 const groupConfig = computed(() => {
-	return {
-		...new Transform(props.data.mat).decompose()
-	}
-})
-</script>
+  return {
+    ...new Transform(props.data.mat).decompose(),
+  };
+});
+</script>

+ 16 - 2
src/core/components/index.ts

@@ -17,8 +17,9 @@ import { LineData } from './line'
 import { TextData } from './text'
 import { IconData } from './icon'
 import { ImageData } from './image'
+import { Pos } from '@/utils/math'
 
-export const components = {
+const _components = {
 	arrow,
 	rectangle,
 	circle,
@@ -30,7 +31,13 @@ export const components = {
 	image
 }
 
-export type Components = typeof components
+export const components = _components as Components
+
+export type Components = {
+	[key in keyof typeof _components]: (typeof _components)[key] & {
+		getSnapInfos?: (items: DrawItem<key>) => ComponentSnapInfo[]
+	}
+}
 export type ComponentValue<T extends ShapeType, K extends keyof Components[T]> = Components[T][K]
 
 export type DrawDataItem = {
@@ -51,3 +58,10 @@ export type DrawData = {
 }
 
 export type DrawItem<T extends ShapeType = ShapeType> = DrawDataItem[T]
+
+export type ComponentSnapInfo = {
+	point: Pos,
+	links: Pos[]
+	linkDirections: Pos[],
+	linkAngle: number[]
+}

+ 14 - 24
src/core/components/line/index.ts

@@ -6,7 +6,8 @@ import {
 } from "../../hook/use-interactive.ts";
 import { LineConfig } from "konva/lib/shapes/Line";
 import { themeMouseColors } from "@/constant/help-style.ts";
-import { BaseItem, getBaseItem } from "../util.ts";
+import { BaseItem, generateSnapInfos, getBaseItem } from "../util.ts";
+import { getMouseColors } from "@/utils/colors.ts";
 
 export { default as Component } from "./line.vue";
 export { default as TempComponent } from "./temp-line.vue";
@@ -18,36 +19,25 @@ export const defaultStyle = {
 };
 
 export const addMode = "dots";
-export const style = {
-  default: defaultStyle,
-  focus: {
-    stroke: themeMouseColors.hover,
-  },
-  hover: {
-    stroke: themeMouseColors.theme,
-  },
+
+export const getMouseStyle = (data: LineData) => {
+  const strokeStatus = getMouseColors(data.stroke || defaultStyle.stroke);
+  const strokeWidth = data.strokeWidth || defaultStyle.strokeWidth;
+
+  return {
+    default: { stroke: strokeStatus.pub, strokeWidth },
+    hover: { stroke: strokeStatus.hover },
+    press: { stroke: strokeStatus.press },
+  };
 };
 
+export const getSnapInfos = (data: LineData) => generateSnapInfos(data.points, true, false)
+
 export type LineData = Partial<typeof defaultStyle> & BaseItem & {
   points: Pos[];
   attitude: number[];
 };
 
-export const dataToConfig = (data: LineData): LineConfig => ({
-  ...defaultStyle,
-  ...data,
-  points: flatPositions(data.points),
-  hitFunc(con, shape) {
-    con.beginPath();
-    con.moveTo(data.points[0].x, data.points[0].y);
-    for (let i = 1; i < data.points.length; i++) {
-      con.lineTo(data.points[i].x, data.points[i].y);
-    }
-    con.closePath();
-    con.fillStrokeShape(shape);
-  },
-});
-
 export const interactiveToData = (
   info: InteractiveMessage,
   preset: Partial<LineData> = {}

+ 28 - 19
src/core/components/line/line.vue

@@ -1,26 +1,35 @@
 <template>
-  <v-line :config="dataToConfig(data)" ref="shape"> </v-line>
+  <TempLine :data="tData" :ref="(v: any) => shape = v?.shape" :id="data.id" />
+  <PropertyUpdate
+    :describes="describes"
+    :data="data"
+    :target="shape"
+    @change="emit('updateShape', { ...data })"
+  />
+  <Operate :target="shape" :menus="operateMenus" />
 </template>
 
 <script lang="ts" setup>
-import { LineData, dataToConfig, style } from "./index.ts";
-import { useMouseStyle } from "../../hook/use-mouse-status.ts";
-import { ref } from "vue";
-import { useLineTransformer } from "../../hook/use-transformer.ts";
-import { DC } from "@/deconstruction.js";
-import { useAniamtion } from "../../hook/use-animation.ts";
-import { Line } from "konva/lib/shapes/Line";
+import { LineData, getMouseStyle, defaultStyle } from "./index.ts";
+import { useComponentStatus } from "@/core/hook/use-component.ts";
+import { PropertyUpdate, Operate } from "../../propertys";
+import TempLine from "./temp-line.vue";
 
 const props = defineProps<{ data: LineData }>();
-const emit = defineEmits<{ (e: "update", value: LineData): void }>();
-const shape = ref<DC<Line>>();
-useLineTransformer(
-  shape,
-  () => props.data,
-  (newData) => {
-    emit("update", { ...props.data, ...newData });
-  }
-);
-const { currentStyle } = useMouseStyle({ style, shape });
-useAniamtion(currentStyle, shape);
+const emit = defineEmits<{
+  (e: "updateShape", value: LineData): void;
+  (e: "addShape", value: LineData): void;
+  (e: "delShape"): void;
+}>();
+
+const { shape, tData, data, operateMenus, describes } = useComponentStatus({
+  emit,
+  props,
+  getMouseStyle,
+  defaultStyle,
+  copyHandler(tf, data) {
+    data.points = data.points.map((v) => tf.point(v));
+    return data;
+  },
+});
 </script>

+ 34 - 4
src/core/components/line/temp-line.vue

@@ -1,8 +1,38 @@
 <template>
-	<v-line :config="{...dataToConfig(data), opacity: 0.3}" />
+  <v-line
+    :config="{
+      ...data,
+      zIndex: undefined,
+      points: flatPositions(data.points),
+      opacity: addMode ? 0.3 : 1,
+      hitFunc,
+    }"
+    ref="shape"
+  />
 </template>
 
 <script lang="ts" setup>
-import { LineData, dataToConfig } from "./index.ts";
-defineProps<{ data: LineData }>()
-</script>
+import { LineData } from "./index.ts";
+import { flatPositions } from "@/utils/shared.ts";
+import { ref } from "vue";
+import { DC } from "@/deconstruction.js";
+import { Line, LineConfig } from "konva/lib/shapes/Line";
+const props = defineProps<{ data: LineData; addMode?: boolean }>();
+
+const hitFunc: LineConfig["hitFunc"] = (con, shape) => {
+  con.beginPath();
+  con.moveTo(props.data.points[0].x, props.data.points[0].y);
+  for (let i = 1; i < props.data.points.length; i++) {
+    con.lineTo(props.data.points[i].x, props.data.points[i].y);
+  }
+  con.closePath();
+  con.fillStrokeShape(shape);
+};
+
+const shape = ref<DC<Line>>();
+defineExpose({
+  get shape() {
+    return shape.value;
+  },
+});
+</script>

+ 15 - 20
src/core/components/polygon/index.ts

@@ -1,12 +1,11 @@
 import { Pos } from "@/utils/math.ts";
-import { flatPositions } from "@/utils/shared.ts";
 import {
   InteractiveAction,
   InteractiveMessage,
 } from "../../hook/use-interactive.ts";
-import { LineConfig } from "konva/lib/shapes/Line";
 import { themeMouseColors } from "@/constant/help-style.ts";
-import { BaseItem, getBaseItem } from "../util.ts";
+import { BaseItem, generateSnapInfos, getBaseItem } from "../util.ts";
+import { getMouseColors } from "@/utils/colors.ts";
 
 export { default as Component } from "./polygon.vue";
 export { default as TempComponent } from "./temp-polygon.vue";
@@ -18,31 +17,27 @@ export const defaultStyle = {
   fill: "#fff",
 };
 
-export const addMode = "dots";
+export const getMouseStyle = (data: PolygonData) => {
+  const fillStatus = getMouseColors(data.fill || defaultStyle.fill);
+  const strokeStatus = getMouseColors(data.stroke || defaultStyle.stroke);
+  const strokeWidth = data.strokeWidth || defaultStyle.strokeWidth;
 
-export const style = {
-  default: defaultStyle,
-  focus: {
-    fill: themeMouseColors.theme,
-    stroke: themeMouseColors.hover,
-  },
-  hover: {
-    stroke: themeMouseColors.theme,
-  },
+  return {
+    default: { fill: fillStatus.pub, stroke: strokeStatus.pub, strokeWidth },
+    hover: { fill: fillStatus.hover },
+    press: { fill: fillStatus.press },
+  };
 };
 
+export const addMode = "dots";
+
+export const getSnapInfos = (data: PolygonData) => generateSnapInfos(data.points, true, false)
+
 export type PolygonData = Partial<typeof defaultStyle> & BaseItem & {
   points: Pos[];
   attitude: number[];
 };
 
-export const dataToConfig = (data: PolygonData): LineConfig => ({
-  ...defaultStyle,
-  ...data,
-  closed: true,
-  points: flatPositions(data.points),
-});
-
 export const interactiveToData = (
   info: InteractiveMessage,
   preset: Partial<PolygonData> = {}

+ 28 - 19
src/core/components/polygon/polygon.vue

@@ -1,26 +1,35 @@
 <template>
-  <v-line :config="dataToConfig(data)" ref="shape" />
+  <TempLine :data="tData" :ref="(v: any) => shape = v?.shape" :id="data.id" />
+  <PropertyUpdate
+    :describes="describes"
+    :data="data"
+    :target="shape"
+    @change="emit('updateShape', { ...data })"
+  />
+  <Operate :target="shape" :menus="operateMenus" />
 </template>
 
 <script lang="ts" setup>
-import { PolygonData, dataToConfig, style } from "./index.ts";
-import { useMouseStyle } from "../../hook/use-mouse-status.ts";
-import { ref } from "vue";
-import { useLineTransformer } from "../../hook/use-transformer.ts";
-import { DC } from "@/deconstruction.js";
-import { useAniamtion } from "../../hook/use-animation.ts";
-import { Line } from "konva/lib/shapes/Line";
+import { PolygonData, getMouseStyle, defaultStyle } from "./index.ts";
+import { useComponentStatus } from "@/core/hook/use-component.ts";
+import { PropertyUpdate, Operate } from "../../propertys";
+import TempLine from "./temp-polygon.vue";
 
 const props = defineProps<{ data: PolygonData }>();
-const emit = defineEmits<{ (e: "update", value: PolygonData): void }>();
-const shape = ref<DC<Line>>();
-useLineTransformer(
-  shape,
-  () => props.data,
-  (newData) => {
-    emit("update", { ...props.data, ...newData });
-  }
-);
-const { currentStyle } = useMouseStyle({ style, shape });
-useAniamtion(currentStyle, shape);
+const emit = defineEmits<{
+  (e: "updateShape", value: PolygonData): void;
+  (e: "addShape", value: PolygonData): void;
+  (e: "delShape"): void;
+}>();
+
+const { shape, tData, data, operateMenus, describes } = useComponentStatus({
+  emit,
+  props,
+  getMouseStyle,
+  defaultStyle,
+  copyHandler(tf, data) {
+    data.points = data.points.map((v) => tf.point(v));
+    return data;
+  },
+});
 </script>

+ 24 - 4
src/core/components/polygon/temp-polygon.vue

@@ -1,8 +1,28 @@
 <template>
-	<v-line :config="{...dataToConfig(data), opacity: 0.3}" />
+  <v-line
+    :config="{
+      ...data,
+      closed: true,
+      zIndex: undefined,
+      points: flatPositions(data.points),
+      opacity: addMode ? 0.3 : 1,
+    }"
+    ref="shape"
+  />
 </template>
 
 <script lang="ts" setup>
-import { PolygonData, dataToConfig } from "./index.ts";
-defineProps<{ data: PolygonData }>()
-</script>
+import { PolygonData } from "./index.ts";
+import { flatPositions } from "@/utils/shared.ts";
+import { ref } from "vue";
+import { DC } from "@/deconstruction.js";
+import { Line } from "konva/lib/shapes/Line";
+defineProps<{ data: PolygonData; addMode?: boolean }>();
+
+const shape = ref<DC<Line>>();
+defineExpose({
+  get shape() {
+    return shape.value;
+  },
+});
+</script>

+ 3 - 11
src/core/components/rectangle/index.ts

@@ -1,9 +1,9 @@
-import { lineCenter, Pos } from "@/utils/math.ts";
+import { Pos } from "@/utils/math.ts";
 import { InteractiveMessage } from "../../hook/use-interactive.ts";
 import { themeMouseColors } from "@/constant/help-style.ts";
 import { getMouseColors } from "@/utils/colors.ts";
 import { onlyId } from "@/utils/shared.ts";
-import { BaseItem, getBaseItem } from "../util.ts";
+import { BaseItem, generateSnapInfos, getBaseItem } from "../util.ts";
 
 export { default as Component } from "./rectangle.vue";
 export { default as TempComponent } from "./temp-rectangle.vue";
@@ -59,15 +59,7 @@ export const interactiveToData = (
   }
 };
 
-export const getSnapPoints = (data: RectangleData) => {
-  return [
-    lineCenter([data.points[0], data.points[1]]),
-    lineCenter([data.points[1], data.points[2]]),
-    lineCenter([data.points[2], data.points[3]]),
-    lineCenter([data.points[3], data.points[0]]),
-    ...data.points.map(p => ({...p}))
-  ]
-}
+export const getSnapInfos = (data: RectangleData) => generateSnapInfos(data.points, true, false)
 
 export const interactiveFixData = (
   data: RectangleData,

+ 15 - 76
src/core/components/rectangle/rectangle.vue

@@ -1,96 +1,35 @@
 <template>
-  <TempLine
-    :data="{ ...data, ...style }"
-    :ref="(v: any) => shape = v?.shape"
-    :id="data.id"
-  />
+  <TempLine :data="tData" :ref="(v: any) => shape = v?.shape" :id="data.id" />
   <PropertyUpdate
     :describes="describes"
     :data="data"
     :target="shape"
-    @change="emit('update', { ...data })"
+    @change="emit('updateShape', { ...data })"
   />
-  <!-- <Operate :target="shape" :menus="operateMenus" /> -->
+  <Operate :target="shape" :menus="operateMenus" />
 </template>
 
 <script lang="ts" setup>
 import { RectangleData, getMouseStyle, defaultStyle } from "./index.ts";
 import { PropertyUpdate, Operate } from "../../propertys";
-import { useAnimationMouseStyle } from "../../hook/use-mouse-status.ts";
-import { ref } from "vue";
-import { useLineTransformer } from "../../hook/use-transformer.ts";
-import { DC } from "@/deconstruction.js";
-import { Line } from "konva/lib/shapes/Line";
 import TempLine from "./temp-rectangle.vue";
-import { useAutomaticData } from "@/core/hook/use-automatic-data.ts";
-import { generateDescribes } from "@/core/propertys/util.ts";
-import { Delete, DocumentCopy } from "@element-plus/icons-vue";
-import { useGetShapeCopyTransform } from "@/core/hook/use-copy.ts";
-import { onlyId } from "@/utils/shared.ts";
-import { useMouseMigrateTempLayer, useZIndex } from "../../hook/use-layer.ts";
+import { useComponentStatus } from "@/core/hook/use-component.ts";
 
 const props = defineProps<{ data: RectangleData }>();
 const emit = defineEmits<{
-  (e: "update", value: RectangleData): void;
-  (e: "add", value: RectangleData): void;
-  (e: "del"): void;
+  (e: "updateShape", value: RectangleData): void;
+  (e: "addShape", value: RectangleData): void;
+  (e: "delShape"): void;
 }>();
-const shape = ref<DC<Line>>();
-const data = useAutomaticData(() => props.data);
-const [style] = useAnimationMouseStyle({
-  data: data,
-  shape,
-  getMouseStyle,
-});
 
-const describes = generateDescribes(data, defaultStyle, {
-  stroke: { type: "color", label: "边框" },
-  fill: { type: "color", label: "填充" },
-  strokeWidth: { type: "num", label: "粗细", props: { min: 0.5, max: 10 } },
-  zIndex: { type: "num", label: "层级", props: { min: -1000, max: 1000, step: 1 } },
-  dash: {
-    type: "num",
-    label: "虚线比例",
-    props: { min: 0, max: 30 },
-    get value() {
-      if (!data.value.dash) {
-        return 30;
-      }
-      if (!data.value.dash[1]) {
-        return 30;
-      } else {
-        return data.value.dash[0];
-      }
-    },
-    set value(val) {
-      data.value.dash = [val, 30 - val];
-    },
+const { shape, tData, data, operateMenus, describes } = useComponentStatus({
+  emit,
+  props,
+  getMouseStyle,
+  defaultStyle,
+  copyHandler(tf, data) {
+    data.points = data.points.map((v) => tf.point(v));
+    return data;
   },
 });
-
-const getCopyTransform = useGetShapeCopyTransform(shape);
-const operateMenus = [
-  {
-    label: "删除",
-    icon: Delete,
-    handler() {
-      emit("del");
-    },
-  },
-  {
-    label: "复制",
-    icon: DocumentCopy,
-    handler() {
-      const transform = getCopyTransform();
-      const copyData = JSON.parse(JSON.stringify(data.value)) as RectangleData;
-      copyData.points = copyData.points.map((v) => transform.point(v));
-      copyData.id = onlyId();
-      emit("add", copyData);
-    },
-  },
-];
-
-useLineTransformer(shape, data, (newData) => emit("update", newData));
-useZIndex(shape, data);
-useMouseMigrateTempLayer(shape);
 </script>

+ 13 - 20
src/core/components/triangle/index.ts

@@ -1,9 +1,8 @@
 import { InteractiveMessage } from "../../hook/use-interactive.ts";
-import { LineConfig } from "konva/lib/shapes/Line";
 import { Pos } from "@/utils/math.ts";
-import { flatPositions } from "@/utils/shared.ts";
 import { themeMouseColors } from "@/constant/help-style.ts";
-import { BaseItem, getBaseItem } from "../util.ts";
+import { BaseItem, generateSnapInfos, getBaseItem } from "../util.ts";
+import { getMouseColors } from "@/utils/colors.ts";
 
 export { default as Component } from "./triangle.vue";
 export { default as TempComponent } from "./temp-triangle.vue";
@@ -17,28 +16,22 @@ export const defaultStyle = {
 
 export const addMode = "area";
 
-export const style = {
-  default: defaultStyle,
-  focus: {
-    fill: themeMouseColors.theme,
-    stroke: themeMouseColors.hover,
-  },
-  hover: {
-    stroke: themeMouseColors.theme,
-  },
+export const getMouseStyle = (data: TriangleData) => {
+  const fillStatus = getMouseColors(data.fill || defaultStyle.fill);
+  const strokeStatus = getMouseColors(data.stroke || defaultStyle.stroke);
+  const strokeWidth = data.strokeWidth || defaultStyle.strokeWidth;
+
+  return {
+    default: { fill: fillStatus.pub, stroke: strokeStatus.pub, strokeWidth },
+    hover: { fill: fillStatus.hover },
+    press: { fill: fillStatus.press },
+  };
 };
 
 export type TriangleData = Partial<typeof defaultStyle> &
   BaseItem & { points: Pos[]; attitude: number[] };
 
-export const dataToConfig = (data: TriangleData): LineConfig => {
-  return {
-    ...data,
-    closed: true,
-    points: flatPositions(data.points),
-    attitude: [1, 0, 0, 1, 0, 0],
-  };
-};
+export const getSnapInfos = (data: TriangleData) => generateSnapInfos(data.points, true, false)
 
 export const interactiveToData = (
   info: InteractiveMessage,

+ 24 - 4
src/core/components/triangle/temp-triangle.vue

@@ -1,8 +1,28 @@
 <template>
-	<v-line :config="{...dataToConfig(data), opacity: 0.3}" />
+  <v-line
+    :config="{
+      ...data,
+      zIndex: undefined,
+      closed: true,
+      points: flatPositions(data.points),
+      opacity: addMode ? 0.3 : 1,
+    }"
+    ref="shape"
+  />
 </template>
 
 <script lang="ts" setup>
-import { TriangleData, dataToConfig } from "./index.ts";
-defineProps<{ data: TriangleData }>()
-</script>
+import { DC } from "@/deconstruction.js";
+import { TriangleData } from "./index.ts";
+import { Line } from "konva/lib/shapes/Line";
+import { ref } from "vue";
+import { flatPositions } from "@/utils/shared.ts";
+defineProps<{ data: TriangleData; addMode?: boolean }>();
+
+const shape = ref<DC<Line>>();
+defineExpose({
+  get shape() {
+    return shape.value;
+  },
+});
+</script>

+ 28 - 19
src/core/components/triangle/triangle.vue

@@ -1,26 +1,35 @@
 <template>
-  <v-line :config="dataToConfig(data)" ref="shape"> </v-line>
+  <TempLine :data="tData" :ref="(v: any) => shape = v?.shape" :id="data.id" />
+  <PropertyUpdate
+    :describes="describes"
+    :data="data"
+    :target="shape"
+    @change="emit('updateShape', { ...data })"
+  />
+  <Operate :target="shape" :menus="operateMenus" />
 </template>
 
 <script lang="ts" setup>
-import { TriangleData, style, dataToConfig } from "./index.ts";
-import { useMouseStyle } from "../../hook/use-mouse-status.ts";
-import { ref } from "vue";
-import { useLineTransformer } from "../../hook/use-transformer.ts";
-import { DC } from "@/deconstruction.js";
-import { useAniamtion } from "../../hook/use-animation.ts";
-import { Line } from "konva/lib/shapes/Line";
+import { TriangleData, getMouseStyle, defaultStyle } from "./index.ts";
+import { PropertyUpdate, Operate } from "../../propertys";
+import TempLine from "./temp-triangle.vue";
+import { useComponentStatus } from "@/core/hook/use-component.ts";
 
 const props = defineProps<{ data: TriangleData }>();
-const emit = defineEmits<{ (e: "update", value: TriangleData): void }>();
-const shape = ref<DC<Line>>();
-useLineTransformer(
-  shape,
-  () => props.data,
-  (newData) => {
-    emit("update", { ...props.data, ...newData });
-  }
-);
-const { currentStyle } = useMouseStyle({ style, shape });
-useAniamtion(currentStyle, shape);
+const emit = defineEmits<{
+  (e: "updateShape", value: TriangleData): void;
+  (e: "addShape", value: TriangleData): void;
+  (e: "delShape"): void;
+}>();
+
+const { shape, tData, operateMenus, describes } = useComponentStatus({
+  emit,
+  props,
+  getMouseStyle,
+  defaultStyle,
+  copyHandler(tf, data) {
+    data.points = data.points.map((v) => tf.point(v));
+    return data;
+  },
+});
 </script>

+ 64 - 7
src/core/components/util.ts

@@ -1,13 +1,70 @@
-import { onlyId } from "@/utils/shared"
+import { lineVector, Pos, vectorAngle } from "@/utils/math";
+import { onlyId, rangMod } from "@/utils/shared";
+import { MathUtils } from "three";
+import { ComponentSnapInfo } from ".";
 
 export type BaseItem = {
-	id: string,
-	createTime: number,
-	zIndex: number
-}
+  id: string;
+  createTime: number;
+  zIndex: number;
+};
 
 export const getBaseItem = (): BaseItem => ({
   id: onlyId(),
   createTime: Date.now(),
-  zIndex: 0
-})
+  zIndex: 0,
+});
+
+export const getRectSnapPoints = (
+  w: number,
+  h: number,
+  x = -w / 2,
+  y = -h / 2
+) => {
+  const r = w + x;
+  const b = h + y;
+  return [
+    { x: x, y: y },
+    { x: r, y: y },
+    { x: r, y: b },
+    { x: x, y: b },
+    { x: x + w / 2, y: y + h / 2 },
+  ];
+};
+
+export const generateSnapInfos = (
+  geo: Pos[],
+  hvAxis = true,
+  link = true
+): ComponentSnapInfo[] => {
+  const len = geo.length;
+  return geo.map((point, ndx) => {
+    const links: Pos[] = [];
+    const linkDirections: Pos[] = [];
+    const linkAngle: number[] = [];
+
+    if (link) {
+      const prev = geo[rangMod(ndx - 1, len)];
+      const next = geo[rangMod(ndx + 1, len)];
+      const prevVector = lineVector([point, prev]);
+      const nextVector = lineVector([point, next]);
+      links.push(prev, next);
+      linkDirections.push(prevVector, nextVector);
+      linkAngle.push(
+        rangMod(MathUtils.radToDeg(vectorAngle(prevVector)), 180),
+        rangMod(MathUtils.radToDeg(vectorAngle(nextVector)), 180)
+      );
+    }
+    if (hvAxis) {
+      linkDirections.push({ x: 1, y: 0 }, { y: 1, x: 0 });
+      linkAngle.push(0, 90);
+    }
+
+    return {
+      point,
+      links,
+      linkDirections,
+      linkAngle,
+    };
+  });
+};

+ 1 - 1
src/core/helper/active-boxs.vue

@@ -25,7 +25,7 @@ import { useDashAnimation } from "../hook/use-animation";
 
 const status = useMouseShapesStatus();
 const boxs = reactive(new WeakMap<EntityShape, IRect>());
-const padding = 8;
+const padding = 1;
 
 const updateBox = ($shape: EntityShape) => {
   const rect = $shape.getClientRect();

+ 63 - 44
src/core/helper/snap-lines.vue

@@ -1,27 +1,28 @@
 <template>
   <v-line
-    v-for="(xAxis, i) in pointer.xAxiss"
-    :config="{ ...vConfig, x: xAxis, y: 0 }"
+    v-for="(axisConfig, i) in axisConfigs"
+    :config="{ ...config, ...axisConfig }"
     :ref="(l: any) => lines[i] = l"
   />
-  <v-line
-    v-for="(yAxiss, j) in pointer.yAxiss"
-    :config="{ ...hConfig, x: 0, y: yAxiss }"
-    :ref="(l: any) => lines[pointer.xAxiss.length + j] = l"
+  <v-circle
+    v-for="axisConfig in axisConfigs"
+    :config="{ x: axisConfig.x, y: axisConfig.y, radius: 3, fill: '#000' }"
   />
 </template>
 
 <script lang="ts" setup>
-import { computed, onUnmounted, reactive, ref, watchEffect } from "vue";
-import { useStage } from "../hook/use-global-vars";
-import { useSnapInfo } from "../hook/use-snap";
+import { computed, Ref, ref } from "vue";
+import { SnapInfo, useGlobalSnapInfos, useSnapResultInfo } from "../hook/use-snap";
 import { useViewerTransform } from "../hook/use-viewer";
 import { RectConfig } from "konva/lib/shapes/Rect";
 import { themeColor } from "@/constant/help-style";
 import { DC } from "@/deconstruction";
 import { Line } from "konva/lib/shapes/Line";
 import { useDashAnimation } from "../hook/use-animation";
-import { listener } from "@/utils/event";
+import { useResize } from "../hook/use-event";
+import { lineLen, Pos, vector, vector2IncludedAngle } from "@/utils/math";
+import { flatPositions } from "@/utils/shared";
+import { MathUtils } from "three";
 
 const config: RectConfig = {
   dash: [10, 10],
@@ -29,50 +30,68 @@ const config: RectConfig = {
   strokeWidth: 1,
   listening: false,
 };
-const vConfig = reactive({
-  ...config,
-  points: [0, 0, 0, 0],
-});
-const hConfig = reactive({
-  ...config,
-  points: [0, 0, 0, 0],
-});
 
-const stage = useStage();
-const setSize = () => {
-  const $stage = stage.value?.getNode();
-  if (!$stage) return;
-  vConfig.points[3] = $stage.height();
-  hConfig.points[2] = $stage.width();
-};
-watchEffect(setSize);
-onUnmounted(listener(window, "resize", setSize));
+type Axis = { point: Pos; direction: Pos; color?: string };
+
+// const debug = import.meta.env.DEV;
+const debug = false;
+const size = useResize();
 
 const viewerTransform = useViewerTransform();
-const info = useSnapInfo();
+const info = useSnapResultInfo();
 const minOffset = 10;
-const deduplication = (nums: number[]) => {
-  for (let i = 0; i < nums.length; i++) {
-    const min = nums[i] - minOffset;
-    const max = nums[i] + minOffset;
+const minAngle = MathUtils.degToRad(5);
+let snapInfos: Ref<SnapInfo[]>;
+if (debug) {
+  snapInfos = useGlobalSnapInfos();
+}
 
-    for (let j = i + 1; j < nums.length; j++) {
-      if (nums[j] < max && nums[j] > min) {
-        nums.splice(j--, 1);
+const deduplication = (items: Axis[]) => {
+  for (let i = 0; i < items.length; i++) {
+    const direction = items[i].direction;
+    const point = items[i].point;
+
+    for (let j = i + 1; j < items.length; j++) {
+      if (
+        vector2IncludedAngle(items[j].direction, direction) < minAngle &&
+        lineLen(items[j].point, point) < minOffset
+      ) {
+        items.splice(j--, 1);
       }
     }
   }
-  return nums;
+  return items;
 };
-const pointer = computed(() => {
-  const xAxiss = info.xSnaps.map(
-    (xSnap) => viewerTransform.value.point({ x: xSnap.ref.x, y: 0 }).x
-  );
-  const yAxiss = info.ySnaps.map(
-    (ySnap) => viewerTransform.value.point({ y: ySnap.ref.y, x: 0 }).y
-  );
 
-  return { xAxiss: deduplication(xAxiss), yAxiss: deduplication(yAxiss) };
+const axiss = computed((): Axis[] => {
+  const axiss = info.attractSnaps.map((snap) => ({
+    point: snap.join,
+    direction: snap.refDirection,
+  }));
+
+  if (debug && !axiss.length) {
+    axiss.push(
+      ...snapInfos.value.flatMap((info) => {
+        return info.linkDirections.map((item) => ({
+          point: info.point,
+          direction: item,
+        }));
+      })
+    );
+  }
+  return axiss;
+});
+
+const axisConfigs = computed(() => {
+  if (!size.value) return;
+  const len = Math.max(size.value.width, size.value.height);
+  return axiss.value.map((item) => ({
+    ...viewerTransform.value.point(item.point),
+    points: flatPositions([
+      vector(item.direction).multiplyScalar(-len),
+      vector(item.direction).multiplyScalar(len),
+    ]),
+  }));
 });
 
 const lines = ref<DC<Line>[]>([]);

+ 1 - 1
src/core/hook/use-automatic-data.ts

@@ -9,6 +9,6 @@ export const useAutomaticData = <T>(
 	const data = ref() as Ref<T>
 	watch(getter, (newData) => {
 		data.value = copy(newData)
-	}, { immediate: true })
+	}, { immediate: true, deep: true })
 	return data;
 }

+ 145 - 0
src/core/hook/use-component.ts

@@ -0,0 +1,145 @@
+import { DC, EntityShape } from "@/deconstruction";
+import { computed, EmitFn, Ref, ref } from "vue";
+import { useAutomaticData } from "./use-automatic-data";
+import { useMouseMigrateTempLayer, useZIndex } from "./use-layer";
+import { useAnimationMouseStyle } from "./use-mouse-status";
+import { DrawItem } from "../components";
+import {
+  useCompTransformer,
+  useLineTransformer,
+} from "./use-transformer";
+import { useGetShapeCopyTransform } from "./use-copy";
+import { Delete, DocumentCopy } from "@element-plus/icons-vue";
+import { onlyId } from "@/utils/shared";
+import { Shape } from "konva/lib/Shape";
+import { Transform } from "konva/lib/Util";
+import { generateDescribes } from "../propertys/util";
+import { PropertyDescribes } from "../propertys";
+
+type Emit<T> = EmitFn<{
+  updateShape: (value: T) => void;
+  addShape: (value: T) => void;
+  delShape: () => void;
+}>;
+
+export type UseComponentStatusProps<T extends DrawItem> = {
+  emit: Emit<T>;
+  props: { data: T };
+  getMouseStyle: any;
+  defaultStyle: any;
+  line?: boolean;
+  getRepShape?: () => Shape;
+  copyHandler: (transform: Transform, data: T) => T;
+};
+
+const getPropertyDescribes = (data: Ref<any>): PropertyDescribes => ({
+  stroke: { type: "color", label: "边框" },
+  fill: { type: "color", label: "填充" },
+  strokeWidth: { type: "num", label: "粗细", props: { min: 0.5, max: 10 } },
+  zIndex: {
+    type: "num",
+    label: "层级",
+    props: { min: -1000, max: 1000, step: 1 },
+  },
+  dash: {
+    type: "num",
+    label: "虚线比例",
+    props: { min: 0, max: 30 },
+    get value() {
+      if (!data.value.dash) {
+        return 30;
+      }
+      if (!data.value.dash[1]) {
+        return 30;
+      } else {
+        return data.value.dash[0];
+      }
+    },
+    set value(val) {
+      data.value.dash = [val, 30 - val];
+    },
+  },
+});
+
+export const useComponentStatus = <S extends EntityShape, T extends DrawItem>(
+  args: UseComponentStatusProps<T>
+) => {
+  const {
+    emit,
+    props,
+    getMouseStyle,
+    defaultStyle,
+    line = true,
+    getRepShape,
+    copyHandler,
+  } = args;
+
+  const shape = ref<DC<S>>();
+  const data = useAutomaticData(() => props.data);
+  const [style] = useAnimationMouseStyle({
+    data: data,
+    shape,
+    getMouseStyle,
+  }) as any;
+
+  if (line) {
+    useLineTransformer(
+      shape as any,
+      data as any,
+      (newData) => {
+        emit("updateShape", newData as T);
+      },
+      getRepShape as any
+    );
+  } else {
+    useCompTransformer(
+      shape,
+      data as any,
+      (nData) => {
+        emit("updateShape", nData as any);
+      },
+    );
+  }
+
+  useZIndex(shape, data);
+  useMouseMigrateTempLayer(shape);
+
+  const getCopyTransform = useGetShapeCopyTransform(shape);
+  const operateMenus = [
+    {
+      label: "删除",
+      icon: Delete,
+      handler() {
+        emit("delShape");
+      },
+    },
+    {
+      label: "复制",
+      icon: DocumentCopy,
+      handler() {
+        const transform = getCopyTransform();
+        const copyData = copyHandler(
+          transform,
+          JSON.parse(JSON.stringify(data.value)) as T
+        );
+        copyData.id = onlyId();
+        emit("addShape", copyData);
+      },
+    },
+  ];
+
+  const describes = generateDescribes(
+    data,
+    defaultStyle,
+    getPropertyDescribes(data)
+  );
+
+  return {
+    data,
+    style,
+    tData: computed(() => ({ ...defaultStyle, ...data.value, ...style.value })),
+    shape,
+    operateMenus,
+    describes,
+  };
+};

+ 2 - 1
src/core/hook/use-copy.ts

@@ -12,7 +12,7 @@ export const useGetShapeCopyTransform = (shape: Ref<DC<EntityShape> | undefined>
   return () => {
     const $shape = shape.value!.getNode()
     const $stage = stage.value!.getNode()
-    const shapeRect = $shape.getClientRect()
+    const shapeRect = $shape.getClientRect({ skipTransform: false })
     const stageRect = {
       x: 0,
       y: 0,
@@ -54,6 +54,7 @@ export const useGetShapeCopyTransform = (shape: Ref<DC<EntityShape> | undefined>
     const origin = invViewTransform.value.point({x: 0, y: 0})
     const target = invViewTransform.value.point(translate);
     // 转化为真实坐标
+    console.log(target.x - origin.x, target.y - origin.y)
     return  new Transform().translate(target.x - origin.x, target.y - origin.y)
   }
 }

+ 456 - 280
src/core/hook/use-snap.ts

@@ -1,364 +1,540 @@
 import { useStore } from "../store";
-import { components, DrawItem, ShapeType } from "../components";
-import { computed, reactive, watch } from "vue";
-import { calcScaleFactor, lineLen, Pos, vector } from "@/utils/math";
+import {
+  components,
+  DrawItem,
+  ShapeType,
+  ComponentSnapInfo,
+} from "../components";
+import { computed, reactive, watch, watchEffect } from "vue";
+import {
+  createLine,
+  eqNGDire,
+  eqPoint,
+  lineIntersection,
+  lineLen,
+  linePointLen,
+  linePointProjection,
+  numEq,
+  Pos,
+  vector,
+  vector2IncludedAngle,
+  verticalVector,
+  zeroEq,
+} from "@/utils/math";
 import { installGlobalVar } from "./use-global-vars";
 import { BaseItem } from "../components/util";
 import {
-  TransformerVectorType,
-  useGetTransformerOrigin,
+  ScaleVectorType,
+  useGetTransformerOperDirection,
+  useGetTransformerOperType,
   useTransformer,
 } from "./use-transformer";
 import { Transform } from "konva/lib/Util";
-import { Circle } from "konva/lib/shapes/Circle";
-import { useFormalLayer, useTempLayer } from "./use-layer";
-import { useViewerInvertTransform, useViewerTransform } from "./use-viewer";
+import { useUnitTransform, useViewerInvertTransform } from "./use-viewer";
 import { MathUtils } from "three";
+import { arrayInsert, rangMod } from "@/utils/shared";
 
-type SnapPoint = Pos & Pick<DrawItem, "id">;
-export const useSnapPoints = installGlobalVar(() => {
+export type SnapInfo = ComponentSnapInfo & Pick<DrawItem, "id">;
+export const useGlobalSnapInfos = installGlobalVar(() => {
   const store = useStore();
   const types = Object.keys(components) as ShapeType[];
-  const points = reactive(new Set<SnapPoint>());
+  const infos = reactive(new Set<SnapInfo>());
 
   for (const type of types) {
-    const api = (components as any)[type]?.getSnapPoints;
-    if (!api) continue;
+    const comp = components[type];
+    if (!("getSnapInfos" in comp)) continue;
     watch(
-      (store as any)[type],
+      () => store[type],
       (items) => {
+        if (!items) return;
         for (const item of items) {
-          watch(
-            () => {
-              const snaps = api(item) as SnapPoint[];
-              snaps.forEach((snap) => (snap.id = item.id));
-              return snaps;
-            },
+          const snaps = computed(
+            () => comp.getSnapInfos!(item as any) as SnapInfo[]
+          );
+          const snapInfoWatchStop = watch(
+            snaps,
             (snaps, _, onCleanup) => {
-              snaps.forEach((snap) => points.add(snap));
+              snaps.forEach((snap) => {
+                snap.id = item.id;
+                infos.add(snap);
+              });
               onCleanup(() => {
-                snaps.forEach((snap) => points.delete(snap));
+                snaps.forEach((snap) => infos.delete(snap));
               });
             },
             { immediate: true }
           );
+          const existsWatchStop = watchEffect(() => {
+            if (!items.includes(item as any)) {
+              snapInfoWatchStop();
+              existsWatchStop();
+            }
+          });
         }
       },
-      { immediate: true, flush: "sync" }
+      { immediate: true }
     );
   }
 
-  return computed(() => Array.from(points.values()));
+  return computed(() => Array.from(infos.values()));
 });
 
-export type SnapInfo = {
-  useXSnap?: AxisSnapInfo,
-  useYSnap?: AxisSnapInfo,
-  xSnaps: AxisSnapInfo[];
-  ySnaps: AxisSnapInfo[];
+export const useSnapConfig = () => {
+  const unitTransform = useUnitTransform();
+  return {
+    get snapOffset() {
+      return unitTransform.getPixel(5);
+    },
+  };
+};
+
+export type SnapResultInfo = {
+  attractSnaps: AttractSnapInfo[];
   clear: () => void;
 };
-export const useSnapInfo = installGlobalVar(() => {
+export const useSnapResultInfo = installGlobalVar(() => {
   const snapInfo = reactive({
-    
-    xSnaps: [],
-    ySnaps: [],
+    attractSnaps: [],
     clear() {
-      snapInfo.xSnaps.length = 0;
-      snapInfo.ySnaps.length = 0;
+      snapInfo.attractSnaps.length = 0;
     },
-  }) as SnapInfo;
+  }) as SnapResultInfo;
   return snapInfo;
 });
 
-type AxisSnapInfo = { ref: Pos; current: Pos; offset: number };
-
-export const getAxisSnapInfos = (
-  refPoints: Pos[],
-  selfPoints: Pos[],
-  snapOffset: number
+export type AttractSnapInfo = {
+  ref: ComponentSnapInfo;
+  current: ComponentSnapInfo;
+  offset: number;
+  angle: number;
+  join: Pos;
+  refDirection: Pos;
+  refLinkNdx: number;
+  currentLinkNdx: number;
+};
+// TODO 返回结果按照self.point参照线多少排序, 子数组按照offset排序
+export const filterAttractSnapInfos = (
+  refInfos: ComponentSnapInfo[],
+  selfInfos: ComponentSnapInfo[],
+  filters: (
+    self: ComponentSnapInfo,
+    ref: ComponentSnapInfo,
+    result: AttractSnapInfo[]
+  ) =>
+    | { items?: AttractSnapInfo[]; stop?: boolean; stopSelf?: boolean }
+    | AttractSnapInfo[],
+  sortKey?: "offset" | "angle"
 ) => {
-  let xSnapInfos: AxisSnapInfo[] = [];
-  for (const snap of refPoints) {
-    for (const current of selfPoints) {
-      const offset = snap.x - current.x;
-      if (Math.abs(offset) < snapOffset) {
-        xSnapInfos.push({
-          ref: snap,
-          current,
-          offset: offset,
-        });
+  const attractSnapInfosGroups: AttractSnapInfo[][] = [];
+  for (const self of selfInfos) {
+    const attractSnapInfos: AttractSnapInfo[] = [];
+    for (const ref of refInfos) {
+      let infos = filters(self, ref, attractSnapInfos);
+      const stop = Array.isArray(infos) ? false : !!infos.stop;
+      const stopRef = Array.isArray(infos) ? false : !!infos.stopSelf;
+      infos = Array.isArray(infos) ? infos : infos.items!;
+      if (infos && sortKey) {
+        for (const info of infos) {
+          arrayInsert(
+            attractSnapInfos,
+            info,
+            (a, b) => b[sortKey] < a[sortKey]
+          );
+        }
+      }
+      if (stop) {
+        return attractSnapInfosGroups;
+      }
+      if (stopRef) {
         break;
       }
     }
+    arrayInsert(
+      attractSnapInfosGroups,
+      attractSnapInfos,
+      (a, b) => a.length < b.length
+    );
+  }
+  return attractSnapInfosGroups;
+};
+
+type FilterAttrib = {
+  maxOffset?: number;
+  maxAngle?: number;
+  type?: "inter" | "projection" | "all" | "none";
+};
+const getAttractSnapInfos = (
+  ref: ComponentSnapInfo,
+  self: ComponentSnapInfo,
+  filter: FilterAttrib
+) => {
+  filter.type = filter.type || "all";
+
+  const limitAngle = "maxAngle" in filter;
+  const limitOffset = "maxOffset" in filter;
+  const isAll = filter.type === "all";
+
+  const attractSnapInfos: AttractSnapInfo[] = [];
+  for (let i = 0; i < self.linkAngle.length; i++) {
+    for (let j = 0; j < ref.linkAngle.length; j++) {
+      const angle = Math.abs(ref.linkAngle[j] - self.linkAngle[i]);
+      if (limitAngle && angle > filter.maxAngle!) {
+        continue;
+      }
+
+      let join: Pos | null = null;
+      let offset: number | null = null;
+
+      const checkOffset = (getJoin: () => Pos | null) => {
+        join = getJoin();
+        offset = join && lineLen(self.point, join);
+        const adopt = join && (!limitOffset || offset! < filter.maxOffset!);
+        if (!adopt) {
+          join = null;
+          offset = null;
+        }
+        return adopt;
+      };
+
+      if (filter.type === "none") {
+        const adopt = checkOffset(() => ref.point);
+        if (!adopt) continue;
+      } else {
+        const refLine = [
+          ref.point,
+          vector(ref.point).add(ref.linkDirections[j]),
+        ];
+        if (filter.type === "projection" || isAll) {
+          const adopt = checkOffset(() =>
+            linePointProjection(refLine, self.point)
+          );
+          if (!adopt && !isAll) continue;
+        }
+
+        const curLine = [
+          self.point,
+          vector(self.point).add(self.linkDirections[i]),
+        ];
+        if (!join && !checkOffset(() => lineIntersection(refLine, curLine))) {
+          continue;
+        }
+      }
+      attractSnapInfos.push({
+        ref,
+        current: self,
+        refLinkNdx: j,
+        currentLinkNdx: i,
+        angle,
+        join: join!,
+        offset: offset!,
+        refDirection: ref.linkDirections[j],
+      });
+    }
   }
+  return attractSnapInfos;
+};
+
+const moveSnap = (
+  refInfos: ComponentSnapInfo[],
+  selfInfos: ComponentSnapInfo[],
+  filter: FilterAttrib
+) => {
+  filter.maxOffset = filter.maxOffset || 15;
+  const exclude = new Map<AttractSnapInfo, AttractSnapInfo>();
+  const addExclude = (nor: AttractSnapInfo, act: AttractSnapInfo) => {
+    exclude.set(nor, act);
+    exclude.set(act, nor);
+  };
+  const getAttractSnapJoin = (nor: AttractSnapInfo, act: AttractSnapInfo) => {
+    if (nor === act || exclude.get(nor) === act) {
+      return void 0;
+    }
+
+    const norJoin = nor.join;
+    const norDire = vector(nor.refDirection);
+    const norLine = createLine(norJoin, norDire);
+
+    // TODO 确保移动前后normal的 direction 保持一致
+    const useDire = act.refDirection;
+    if (eqNGDire(norDire, useDire)) {
+      return addExclude(nor, act);
+    }
+    const useJoin = act.join;
+    const useLine = createLine(useJoin, useDire);
+    const nuJoin = lineIntersection(norLine, useLine);
+
+    if (!nuJoin || lineLen(nuJoin, norJoin) > filter.maxOffset!) {
+      return addExclude(nor, act);
+    } else {
+      return nuJoin;
+    }
+  };
 
-  let ySnapInfos: AxisSnapInfo[] = [];
-  for (const snap of refPoints) {
-    for (const current of selfPoints) {
-      const offset = snap.y - current.y;
-      if (Math.abs(offset) < snapOffset) {
-        ySnapInfos.push({
-          ref: snap,
-          current,
-          offset: offset,
-        });
+  const useAttractSnaps: AttractSnapInfo[] = [];
+  let end: Pos | null = null;
+  let start: Pos | null = null;
+
+  // TODO 最多参考2个信息
+  const attractSnapGroups = filterAttractSnapInfos(
+    refInfos,
+    selfInfos,
+    (self, ref, selfGroup) => {
+      const attractSnapInfos = getAttractSnapInfos(ref, self, filter);
+      const nor = selfGroup[0] || attractSnapInfos[0];
+      const checks = [...selfGroup, ...attractSnapInfos];
+      // TODO 尽快提前结束
+      for (const check of checks) {
+        const join = getAttractSnapJoin(nor, check);
+        if (join) {
+          end = join;
+          start = nor.current.point;
+          useAttractSnaps.push(nor, check);
+          return { stop: true };
+        }
       }
+      return attractSnapInfos;
+    },
+    "offset"
+  );
+
+  if (!end) {
+    if (!attractSnapGroups.length || !attractSnapGroups[0].length) return null;
+    const nor = attractSnapGroups[0][0];
+
+    end = nor.join;
+    start = nor.current.point;
+    useAttractSnaps.push(nor);
+
+    // TODO 如果没有同一个点的两线段,则使用2垂直的两点线段
+    const move = vector(end!).sub(start!);
+    for (let i = 1; i < attractSnapGroups.length; i++) {
+      let j = 0;
+      for (; j < attractSnapGroups[i].length; j++) {
+        const attractSnap = attractSnapGroups[i][j];
+        const rDire = attractSnap.refDirection;
+        const angle = vector2IncludedAngle(nor.refDirection, rDire);
+        if (!numEq(rangMod(angle, Math.PI), Math.PI / 2)) {
+          continue;
+        }
+        const cPoint = vector(attractSnap.current.point).add(move);
+        const rPoint = attractSnap.ref.point;
+        const inter = lineIntersection(
+          createLine(cPoint, nor.refDirection),
+          createLine(rPoint, rDire)
+        );
+
+        if (inter) {
+          useAttractSnaps.push(attractSnap);
+          end = vector(end).add(inter.sub(cPoint));
+          break;
+        }
+      }
+      if (j !== attractSnapGroups[i].length) break;
     }
   }
-  return { xSnapInfos, ySnapInfos };
+
+  const norMove = vector(end!).sub(start!);
+  return {
+    useAttractSnaps,
+    transform: new Transform().translate(norMove.x, norMove.y),
+  };
 };
 
 type SelfAttitude = {
   rotation: number;
   origin: Pos;
+  operTarget: Pos;
   center: Pos;
 };
-const optimumSnapToAxis = (
-  xSnapInfos: AxisSnapInfo[],
-  ySnapInfos: AxisSnapInfo[],
-  type?: TransformerVectorType,
-  attitude?: SelfAttitude
+const scaleSnap = (
+  refInfos: ComponentSnapInfo[],
+  selfInfos: ComponentSnapInfo[],
+  filter: Omit<FilterAttrib, "type">,
+  type: ScaleVectorType,
+  attitude: SelfAttitude
 ) => {
-  if (type === "rotater") return null;
-  const transform = new Transform();
-
-  let minOffset = xSnapInfos[0].offset;
-  let xSnapInfo: AxisSnapInfo | undefined = xSnapInfos[0];
-  for (let i = 0; i < xSnapInfos.length; i++) {
-    if (xSnapInfos[i].offset < minOffset) {
-      xSnapInfo = xSnapInfos[i];
-      minOffset = xSnapInfo.offset;
+  const { origin, operTarget } = attitude;
+  const attractSnaps: AttractSnapInfo[] = [];
+  const operVector = vector(operTarget).sub(origin).normalize();
+  const vOperVector = verticalVector(operVector);
+  const vLine = createLine(origin, vOperVector);
+  const proportional = [
+    "top-right",
+    "top-left",
+    "bottom-right",
+    "bottom-left",
+  ].includes(type);
+
+  const limitOffset = filter.maxOffset;
+  const asFilter: FilterAttrib = { ...filter, type: "none" };
+  delete asFilter.maxOffset;
+
+  const exclude = new Set<AttractSnapInfo>();
+  const getAttractSnapJoin = (nor: AttractSnapInfo) => {
+    if (exclude.has(nor)) return;
+    if (
+      eqNGDire(nor.refDirection, operVector) ||
+      eqNGDire(nor.refDirection, nor.current.linkDirections[nor.currentLinkNdx])
+    ) {
+      exclude.add(nor);
+      return;
     }
-  }
-
-  minOffset = ySnapInfos[0].offset;
-  let ySnapInfo: AxisSnapInfo | undefined = ySnapInfos[0];
-  for (let i = 0; i < ySnapInfos.length; i++) {
-    if (ySnapInfos[i].offset < minOffset) {
-      ySnapInfo = ySnapInfos[i];
-      minOffset = ySnapInfo.offset;
+    const cur = nor.current.point;
+    if (
+      eqNGDire(vOperVector, nor.refDirection) &&
+      zeroEq(linePointLen(vLine, nor.ref.point))
+    ) {
+      exclude.add(nor);
+      attractSnaps.push(nor);
+      return;
+    }
+    const refLine = [
+      nor.ref.point,
+      vector(nor.ref.point).add(nor.refDirection),
+    ];
+    const norLine = proportional
+      ? [origin, cur]
+      : [cur, vector(cur).add(operVector)];
+    const join = lineIntersection(refLine, norLine);
+    if (!join || (limitOffset && lineLen(join, cur) > limitOffset)) {
+      exclude.add(nor);
+      return;
+    }
+    if (!eqPoint(vector(join).sub(cur).normalize(), operVector)) {
+      exclude.add(nor);
+      return;
     }
-  }
 
-  if (!xSnapInfo && !ySnapInfo) return null;
-  if (!type) {
-    return {
-      xSnapInfo,
-      ySnapInfo,
-      transform: transform.translate(
-        xSnapInfo?.offset || 0,
-        ySnapInfo?.offset || 0
-      ),
-    };
-  }
-  if (!attitude) return null;
+    return join;
+  };
 
-  const { center, rotation, origin } = attitude;
+  let useAttractSnap: AttractSnapInfo;
+  let useJoin: Pos;
+
+  // TODO 最多参考1个信息
+  filterAttractSnapInfos(refInfos, selfInfos, (self, ref) => {
+    if (eqPoint(self.point, origin)) return { stopSelf: true };
+    const attractSnapInfos = getAttractSnapInfos(ref, self, asFilter);
+    for (const info of attractSnapInfos) {
+      const join = getAttractSnapJoin(info);
+      if (join) {
+        info.join = join;
+        useJoin = join;
+        useAttractSnap = info;
+        return { stop: true };
+      }
+    }
+    return attractSnapInfos;
+  });
+  if (!useAttractSnap! || !useJoin!) return null;
 
-  const scaleDirection = vector({ x: 0, y: 0 });
-  type.includes("left") && (scaleDirection.x -= 1);
-  type.includes("right") && (scaleDirection.x += 1);
-  type.includes("top") && (scaleDirection.y -= 1);
-  type.includes("bottom") && (scaleDirection.y += 1);
+  const rotation = Math.atan2(operVector.y, operVector.x);
+  const invRotateTransform = new Transform()
+    .rotate(-rotation)
+    .translate(-origin.x, -origin.y);
 
-  scaleDirection.normalize();
-  scaleDirection.rotateAround(
-    { x: origin.x - center.x, y: origin.y - center.y },
-    rotation
-  );
-  if (scaleDirection.x === 0) {
-    xSnapInfo = undefined;
-  }
-  if (scaleDirection.y === 0) {
-    ySnapInfo = undefined;
-  }
-  if (!xSnapInfo && !ySnapInfo) return null;
-
-  const xScaleFactor =
-    xSnapInfo &&
-    (xSnapInfo.ref.x - origin.x) /
-      ((xSnapInfo.current.x - origin.x) * scaleDirection.x);
-  const yScaleFactor =
-    ySnapInfo &&
-    (ySnapInfo.ref.y - origin.y) /
-      ((ySnapInfo.current.y - origin.y) * scaleDirection.y);
-
-  if (!xScaleFactor && !yScaleFactor) return null;
-
-  let scaleFactor;
-  if (xScaleFactor && yScaleFactor) {
-    if (xScaleFactor > yScaleFactor) {
-      scaleFactor = yScaleFactor;
-      xSnapInfo = undefined;
-    } else {
-      scaleFactor = xScaleFactor;
-      ySnapInfo = undefined;
-    }
-  } else if (xScaleFactor) {
-    scaleFactor = xScaleFactor;
-    ySnapInfo = undefined;
-  } else {
-    scaleFactor = yScaleFactor;
-    xSnapInfo = undefined;
+  const t = invRotateTransform.point(useJoin!);
+  const c = invRotateTransform.point(useAttractSnap.current.point);
+
+  const currentFactor = vector({
+    x: numEq(t.x, c.x) ? 1 : t.x / c.x,
+    y: numEq(t.y, c.y) ? 1 : t.y / c.y,
+  });
+  if (proportional) {
+    currentFactor.y = currentFactor.x;
   }
 
-  scaleDirection.multiplyScalar(scaleFactor!);
-  transform
-    .translate(origin.x, origin.y)
-    .scale(scaleDirection.x, scaleDirection.y)
-    .translate(-origin.x, -origin.y);
+  attractSnaps.push(useAttractSnap);
+  return {
+    useAttractSnaps: attractSnaps,
+    transform: new Transform()
+      .multiply(invRotateTransform.copy().invert())
+      .scale(currentFactor.x, currentFactor.y)
+      .multiply(invRotateTransform),
+  };
+};
+
+export const useSnap = (
+  snapInfos: { value: ComponentSnapInfo[] } = useGlobalSnapInfos()
+) => {
+  const snapResultInfo = useSnapResultInfo();
+  const snapConfig = useSnapConfig();
+  const afterHandler = (result: ReturnType<typeof moveSnap>) => {
+    if (result) {
+      snapResultInfo.attractSnaps = result.useAttractSnaps;
+      return result.transform;
+    }
+    return null;
+  };
 
+  const move = (selfSnapInfos: ComponentSnapInfo[]) => {
+    const result = moveSnap(snapInfos.value, selfSnapInfos, {
+      maxOffset: snapConfig.snapOffset,
+    });
+    return afterHandler(result);
+  };
+  const scale = (
+    selfSnapInfos: ComponentSnapInfo[],
+    attitude: SelfAttitude & { type: ScaleVectorType }
+  ) => {
+    const result = scaleSnap(
+      snapInfos.value,
+      selfSnapInfos,
+      { maxOffset: snapConfig.snapOffset },
+      attitude.type,
+      attitude
+    );
+    return afterHandler(result);
+  };
   return {
-    xSnapInfo,
-    ySnapInfo,
-    transform: transform,
+    move,
+    scale,
+    clear: snapResultInfo.clear,
   };
 };
 
-const CanSnapOffset = 15;
-export const useSnapToPoints = (itemId?: string) => {
+export const useComponentSnap = (componentId: string) => {
   const store = useStore();
-  const type = itemId ? store.getType(itemId as any) : undefined;
-  const points = useSnapPoints();
-  const snapInfo = useSnapInfo();
-  const refPoints = computed(() => points.value.filter((p) => p.id !== itemId));
-  const api = type && (components as any)[type]?.getSnapPoints;
+  const type = componentId ? store.getType(componentId) : undefined;
+  const comp = type && components[type];
+  const api = type && comp?.getSnapInfos;
+  if (!api) return null;
+
+  const snapInfos = useGlobalSnapInfos();
+  const refSnapInfos = computed(() =>
+    snapInfos.value.filter((p) => p.id !== componentId)
+  );
+  const baseSnap = useSnap(refSnapInfos);
+  const getOperType = useGetTransformerOperType();
+  const getTransformerOperDirection = useGetTransformerOperDirection();
   const transformer = useTransformer();
-  const getTransformerOrigin = useGetTransformerOrigin();
-  const transform = new Transform();
-  const rotateTransform = new Transform();
-  const invRotateTransform = new Transform();
   const viewerInvertTransform = useViewerInvertTransform();
-  const layer = useTempLayer();
-  const circle = new Circle({ fill: "rgb(255, 0, 0)", width: 20, height: 20 });
 
-  const snap = (item: BaseItem | Pos, snapOffset = CanSnapOffset) => {
-    const operateType = transformer.getActiveAnchor() as TransformerVectorType;
+  const snap = (item: BaseItem) => {
+    const operateType = getOperType();
+    const selfSnapInfos = api(item as any);
+    baseSnap.clear();
 
-    let attitude: SelfAttitude | undefined = void 0;
-    if (operateType && operateType !== "rotater") {
-      const origin = getTransformerOrigin();
-      if (!origin) return null;
+    // move
+    if (!operateType) {
+      return baseSnap.move(selfSnapInfos);
+    } else if (operateType !== "rotater") {
+      const direction = getTransformerOperDirection()!;
       const node = transformer.nodes()[0];
       const rect = node.getClientRect();
-      attitude = {
+      const attitude = {
         center: viewerInvertTransform.value.point({
           x: rect.x + rect.width / 2,
           y: rect.y + rect.height / 2,
         }),
+        operTarget: direction[1],
         rotation: MathUtils.degToRad(node.rotation()),
-        origin,
+        origin: direction[0],
+        type: operateType,
       };
+      return baseSnap.scale(selfSnapInfos, attitude);
     }
-
-    const selfPoints = (api ? api(item) : [item]) as Pos[];
-    const axisInfos = getAxisSnapInfos(refPoints.value, selfPoints, snapOffset);
-
-    snapInfo.clear();
-    snapInfo.xSnaps = axisInfos.xSnapInfos
-    snapInfo.ySnaps = axisInfos.ySnapInfos
-
-    const toAxisResult = optimumSnapToAxis(
-      axisInfos.xSnapInfos,
-      axisInfos.ySnapInfos,
-      operateType,
-      attitude
-    );
-    if (!toAxisResult) return null;
-    
-    snapInfo.useXSnap = toAxisResult.xSnapInfo
-    snapInfo.useYSnap = toAxisResult.xSnapInfo
-    
-    return toAxisResult.transform;
-
-
-    transform.reset();
-    const offset = {
-      x: info.xSnapInfos[0]?.offset || 0,
-      y: info.ySnapInfos[0]?.offset || 0,
-    };
-    if (Math.abs(offset.x) < 0.0001 && Math.abs(offset.y) < 0.0001) return null;
-
-    if (!operateType) {
-      snapInfo.xSnaps = info.xSnapInfos;
-      snapInfo.ySnaps = info.ySnapInfos;
-      return transform.translate(offset.x, offset.y);
-    }
-
-    const origin = getTransformerOrigin();
-    if (!origin) return null;
-
-    const node = transformer.nodes()[0];
-    const rotation = MathUtils.degToRad(node.rotation());
-    const rect = node.getClientRect();
-    const center = viewerInvertTransform.value.point({
-      x: rect.x + rect.width / 2,
-      y: rect.y + rect.height / 2,
-    });
-
-    rotateTransform.reset();
-    rotateTransform
-      .translate(center.x, center.y)
-      .rotate(rotation)
-      .translate(-center.x, -center.y);
-
-    rotateTransform.copyInto(invRotateTransform);
-    invRotateTransform.invert();
-
-    const norOrigin = invRotateTransform.point(origin);
-    let scaleFactor: Partial<Pos> | null = null;
-
-    // const getTarget = (snap: ) => {
-
-    // }
-
-    for (const snap of info.xSnapInfos) {
-      scaleFactor = calcScaleFactor({
-        origin: norOrigin,
-        target: invRotateTransform.point({
-          x: snap.ref.x,
-          y: snap.current.y,
-        }),
-        current: invRotateTransform.point(snap.current),
-      });
-      if (scaleFactor) {
-        console.log("a?");
-        snapInfo.xSnaps = [snap];
-        break;
-      }
-    }
-    if (!scaleFactor) {
-      for (const snap of info.ySnapInfos) {
-        scaleFactor = calcScaleFactor({
-          origin: norOrigin,
-          target: invRotateTransform.point({
-            x: snap.current.x,
-            y: snap.ref.y,
-          }),
-          current: invRotateTransform.point(snap.current),
-        });
-        if (scaleFactor) {
-          console.log("b?");
-          snapInfo.ySnaps = [snap];
-          break;
-        }
-      }
-    }
-
-    if (!scaleFactor) return null;
-
-    const scaleTransform = new Transform()
-      .translate(norOrigin.x, norOrigin.y)
-      .scale(scaleFactor!.x!, 1)
-      .translate(-norOrigin.x, -norOrigin.y);
-
-    transform.reset();
-    return transform
-      .multiply(rotateTransform)
-      .multiply(scaleTransform)
-      .multiply(invRotateTransform);
   };
 
-  return [snap, snapInfo.clear] as const;
+  return [snap, baseSnap.clear] as const;
 };

+ 219 - 86
src/core/hook/use-transformer.ts

@@ -1,7 +1,6 @@
 import { useMouseShapeStatus } from "./use-mouse-status.ts";
 import { Ref, ref, watch } from "vue";
 import { DC, EntityShape } from "../../deconstruction";
-import { Shape } from "konva/lib/Shape";
 import {
   installGlobalVar,
   useMode,
@@ -13,13 +12,18 @@ import { Transform, Util } from "konva/lib/Util";
 import { Pos, vector } from "@/utils/math.ts";
 import { useConversionPosition } from "./use-coversion-position.ts";
 import { getOffset, listener } from "@/utils/event.ts";
-import { flatPositions, mergeFuns } from "@/utils/shared.ts";
+import { flatPositions, mergeFuns, round } from "@/utils/shared.ts";
 import { Line } from "konva/lib/shapes/Line";
 import { setShapeTransform } from "@/utils/shape.ts";
-import { TransformerConfig, Transformer } from "konva/lib/shapes/Transformer";
-import { themeMouseColors } from "@/constant/help-style.ts";
-import { useSnapToPoints } from "./use-snap.ts";
+import { Transformer } from "../transformer.ts";
+import { TransformerConfig } from "konva/lib/shapes/Transformer";
+import { themeColor, themeMouseColors } from "@/constant/help-style.ts";
+import { useComponentSnap } from "./use-snap.ts";
 import { useViewerInvertTransform } from "./use-viewer.ts";
+import { Rect } from "konva/lib/shapes/Rect";
+import { Text } from "konva/lib/shapes/Text";
+import { Group } from "konva/lib/Group";
+import { BaseItem } from "../components/util.ts";
 
 export type TransformerExtends = Transformer & {
   queueShapes: Ref<EntityShape[]>;
@@ -31,6 +35,8 @@ export const useTransformer = installGlobalVar(() => {
     borderStroke: themeMouseColors.pub,
     anchorCornerRadius,
     anchorSize: anchorCornerRadius * 2,
+    rotationSnaps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330, 360],
+    rotationSnapTolerance: 3,
     anchorStrokeWidth: 2,
     anchorStroke: themeMouseColors.pub,
     anchorFill: themeMouseColors.press,
@@ -38,6 +44,37 @@ export const useTransformer = installGlobalVar(() => {
     useSingleNodeRotation: true,
   }) as TransformerExtends;
   transformer.queueShapes = ref([]);
+
+  transformer.on("transformstart.attachText", () => {
+    const operateType = transformer.getActiveAnchor() as TransformerVectorType;
+    if (operateType !== "rotater") return;
+    const $node = transformer.findOne<Rect>(".rotater")!;
+    const $g = new Group();
+    const $text = new Text({
+      listening: false,
+      fill: themeColor,
+      fontSize: 12,
+      width: 100,
+      align: "center",
+      offset: { x: 50, y: 0 },
+    });
+    $g.add($text);
+    $node.parent!.add($g);
+    setShapeTransform($g, $node.getTransform());
+    $g.y($g.y() - 2 * $text.fontSize());
+
+    const updateText = () => {
+      const rotation = transformer.rotation();
+      $text.rotation(-rotation).text(` ${round(rotation, 1)}°`);
+    };
+
+    updateText();
+    transformer.on("transform.attachText", updateText);
+    transformer.on("transformend.attachText", () => {
+      transformer.off("transform.attachText transformend.attachText");
+      $g.destroy();
+    });
+  });
   return transformer;
 }, Symbol("transformer"));
 
@@ -58,7 +95,7 @@ export const usePointerIsTransformerInner = () => {
   };
 };
 
-export type TransformerVectorType =
+export type ScaleVectorType =
   | "middle-left"
   | "middle-right"
   | "top-center"
@@ -66,8 +103,16 @@ export type TransformerVectorType =
   | "top-right"
   | "top-left"
   | "bottom-right"
-  | "bottom-left"
-  | "rotater";
+  | "bottom-left";
+export type TransformerVectorType = ScaleVectorType | "rotater";
+
+export const useGetTransformerOperType = () => {
+  const transformer = useTransformer();
+  return () => {
+    if (!transformer.nodes().length) return null;
+    return transformer.getActiveAnchor() as TransformerVectorType;
+  };
+};
 
 export const useGetTransformerVectors = () => {
   const viewerInvertTransform = useViewerInvertTransform();
@@ -98,8 +143,8 @@ export const useGetTransformerVectors = () => {
   };
 };
 
-export const useGetTransformerOrigin = () => {
-  const transformer = useTransformer();
+export const useGetTransformerOperDirection = () => {
+  const getTransformerOperType = useGetTransformerOperType();
   const getTransformerVectors = useGetTransformerVectors();
   const originTypeMap = {
     "middle-left": "middle-right",
@@ -110,14 +155,15 @@ export const useGetTransformerOrigin = () => {
     "top-left": "bottom-right",
     "bottom-right": "top-left",
     "bottom-left": "top-right",
-    'rotater': "rotater",
+    rotater: "rotater",
   } as const;
 
-  return (): Pos | null => {
-    if (!transformer.nodes().length) return null;
-    const operateType = transformer.getActiveAnchor() as TransformerVectorType;
+  return () => {
+    const operateType = getTransformerOperType();
     if (!operateType || !originTypeMap[operateType]) return null;
-    return getTransformerVectors(originTypeMap[operateType]);
+    const origin = getTransformerVectors(originTypeMap[operateType]);
+    const operTarget = getTransformerVectors(operateType);
+    return origin && operTarget && ([origin, operTarget] as const);
   };
 };
 
@@ -187,7 +233,7 @@ export const useShapeDrag = (shape: Ref<DC<EntityShape> | undefined>) => {
   return offset;
 };
 
-type Rep<T> = { tempShape: T; update: () => void; destory: () => void };
+type Rep<T> = { tempShape: T; update?: () => void; destory: () => void };
 const emptyFn = () => {};
 export const useShapeTransformer = <T extends EntityShape>(
   shape: Ref<DC<T> | undefined>,
@@ -242,7 +288,7 @@ export const useShapeTransformer = <T extends EntityShape>(
           const downHandler = () => {
             isEnter && mode.pop();
             isEnter = true;
-            rep.update();
+            rep.update && rep.update();
             mode.push(Mode.update);
             transformIngShapes.value.push($shape);
           };
@@ -271,7 +317,7 @@ export const useShapeTransformer = <T extends EntityShape>(
             (translate, oldTranslate) => {
               if (translate) {
                 if (!oldTranslate) {
-                  rep.update();
+                  rep.update && rep.update();
                 }
                 const moveTf = new Transform().translate(
                   translate.x,
@@ -331,104 +377,191 @@ export const useShapeTransformer = <T extends EntityShape>(
   return transform;
 };
 
-export const genTransformerRepShape = <T extends Shape>(
-  shape: T,
-  transformer: TransformerExtends,
-  getAttitudeMat: () => number[],
-  resumeData: (repShape: T, inverAttitude: Transform) => void
-) => {
-  const repShape = shape.clone({
-    fill: "rgb(0, 255, 0)",
-    visible: false,
-    strokeWidth: 0,
-  });
-
-  const update = () => {
-    const attitude = new Transform(getAttitudeMat());
-    const inverAttitude = attitude.copy().invert();
-    setShapeTransform(repShape, attitude);
-    resumeData(repShape, inverAttitude);
+export const cloneRepShape = <T extends EntityShape>(
+  shape: T
+): { shape: T } => {
+  return {
+    shape: shape.clone({
+      fill: "rgb(0, 255, 0)",
+      visible: false,
+      strokeWidth: 0,
+    }),
   };
+};
 
+export const transformerRepShapeHandler = <T extends EntityShape>(
+  transformer: TransformerExtends,
+  shape: T,
+  repShape: T
+) => {
   if (import.meta.env.DEV) {
     repShape.visible(true);
     shape.opacity(0.9);
     repShape.opacity(0.1);
   }
-
-  update();
   shape.parent!.add(repShape);
   repShape.zIndex(shape.getZIndex());
   transformer.nodes([repShape]);
   transformer.queueShapes.value = [shape];
 
-  return {
-    tempShape: repShape,
-    update,
-    destory: () => {
+  return [
+    repShape,
+    () => {
       repShape.remove();
       shape.opacity(1);
     },
-  };
+  ] as const;
+};
+
+type GetRepShape<T extends EntityShape, K extends object> = (shape: T) => {
+  shape: T;
+  update?: (data: K, shape: T) => void;
+};
+export type CustomTransformerProps<
+  T extends BaseItem,
+  S extends EntityShape
+> = {
+  openSnap?: boolean;
+  getRepShape?: GetRepShape<S, T>;
+  beforeHandler?: (data: T, mat: Transform) => T;
+  handler?: (data: T, mat: Transform) => Transform | void;
+  callback?: (data: T) => void;
+  transformerConfig?: TransformerConfig;
+};
+
+export const useCustomTransformer = <T extends BaseItem, S extends EntityShape>(
+  shape: Ref<DC<S> | undefined>,
+  data: Ref<T>,
+  props: CustomTransformerProps<T, S>
+) => {
+  const { getRepShape, handler, callback, openSnap, transformerConfig } = props;
+  const needSnap = openSnap && useComponentSnap(data.value.id);
+  const transformer = useTransformer();
+  let repResult: ReturnType<GetRepShape<S, T>>;
+  const transform = useShapeTransformer(
+    shape,
+    transformerConfig,
+    getRepShape &&
+      ((transformer: TransformerExtends, shape) => {
+        repResult = getRepShape(shape);
+        const [_, destory] = transformerRepShapeHandler(
+          transformer,
+          shape,
+          repResult.shape
+        );
+        return {
+          tempShape: repResult.shape,
+          update: () => {
+            repResult.update && repResult.update(data.value, repResult.shape);
+          },
+          destory,
+        };
+      })
+  );
+  watch(transform, (current, oldTransform) => {
+    if (current) {
+      if (!handler) return;
+      const snapData = props.beforeHandler
+        ? props.beforeHandler(data.value, current)
+        : data.value;
+
+      let nTransform;
+      if (needSnap && (nTransform = needSnap[0](snapData))) {
+        current = nTransform.multiply(current);
+      }
+      const mat = handler(data.value, current);
+      if (mat) {
+        if (repResult.update) {
+          repResult.update(data.value, repResult.shape);
+        } else {
+          setShapeTransform(repResult.shape, mat);
+        }
+        transformer.forceUpdate();
+      }
+    } else if (oldTransform) {
+      needSnap && needSnap[1]();
+      callback && callback(data.value);
+    }
+  });
+  return transform;
 };
 
-export type LineTransformerData = {
+export type LineTransformerData = BaseItem & {
   points: Pos[];
   attitude: number[];
 };
+
 export const useLineTransformer = <T extends LineTransformerData>(
   shape: Ref<DC<Line> | undefined>,
   data: Ref<T>,
-  callback: (data: T) => void
+  callback: (data: T) => void,
+  genRepShape?: ($shape: Line) => Line
 ) => {
   let tempShape: Line;
   let inverAttitude: Transform;
   let stableVs = data.value.points;
   let tempVs = data.value.points;
-  let operateShape: Line;
 
-  const transformer = useTransformer();
-  const transform = useShapeTransformer(
-    shape,
-    undefined,
-    (transformer, $shape) => {
-      const result = genTransformerRepShape(
-        $shape,
-        transformer,
-        () => data.value.attitude,
-        (repShape, inverMat) => {
-          const initVs = stableVs.map((v) => inverMat.point(v));
-          repShape.points(flatPositions(initVs));
-          repShape.closed(true);
-          inverAttitude = inverMat;
-          operateShape = repShape;
-        }
-      );
-      tempShape = result.tempShape;
-      return result;
-    }
-  );
-
-  const [snapToPoints, clearSnap] = useSnapToPoints((data as any).value.id);
-  watch(transform, (current, prev) => {
-    if (current) {
+  useCustomTransformer(shape, data, {
+    openSnap: true,
+    beforeHandler(data, mat) {
+      const transfrom = mat.copy().multiply(inverAttitude);
+      return {
+        ...data,
+        points: stableVs.map((v) => transfrom.point(v)),
+      };
+    },
+    handler(data, mat) {
       // 顶点更新
-      const transfrom = current.copy().multiply(inverAttitude);
-      tempVs = stableVs.map((v) => transfrom.point(v));
-      const snapTransform = snapToPoints({
-        ...data.value,
-        points: tempVs,
-      } as any);
-      if (snapTransform) {
-        tempVs = tempVs.map((v) => snapTransform.point(v));
+      const transfrom = mat.copy().multiply(inverAttitude);
+      data.points = tempVs = stableVs.map((v) => transfrom.point(v));
+    },
+    callback(data) {
+      data.attitude = tempShape.getTransform().m;
+      data.points = stableVs = tempVs;
+      callback(data);
+    },
+    getRepShape($shape) {
+      let repShape: Line;
+      if (genRepShape) {
+        repShape = genRepShape($shape);
+      } else {
+        repShape = cloneRepShape($shape).shape;
       }
-      data.value.points = tempVs;
-    } else if (prev && tempShape) {
-      data.value.attitude = tempShape.getTransform().m;
-      data.value.points = stableVs = tempVs;
-      callback(data.value);
-      clearSnap();
-    }
+      tempShape = repShape;
+      const update = (data: T) => {
+        const attitude = new Transform(data.attitude);
+        const inverMat = attitude.copy().invert();
+        setShapeTransform(repShape, attitude);
+        const initVs = stableVs.map((v) => inverMat.point(v));
+        repShape.points(flatPositions(initVs));
+        repShape.closed(true);
+        inverAttitude = inverMat;
+      };
+      update(data.value);
+
+      return {
+        update,
+        shape: repShape,
+      };
+    },
   });
-  return transform;
 };
+
+export const useCompTransformer = <T extends BaseItem & { mat: number[] }>(
+  shape: Ref<DC<EntityShape> | undefined>,
+  data: Ref<T>,
+  callback: (data: T) => void
+) => {
+  return useCustomTransformer(shape, data, {
+    beforeHandler(data, mat) {
+      return { ...data, mat: mat.m };
+    },
+    handler(data, mat) {
+      data.mat = mat.m;
+    },
+    getRepShape: cloneRepShape,
+    callback,
+    openSnap: true,
+  });
+};

+ 41 - 1
src/core/hook/use-viewer.ts

@@ -1,10 +1,11 @@
 import { Viewer } from "../viewer.ts";
-import { computed, ref } from "vue";
+import { computed, ref, watch } from "vue";
 import { dragListener, scaleListener } from "../../utils/event.ts";
 import { globalWatch, installGlobalVar, useMode, useStage } from "./use-global-vars.ts";
 import { Mode } from "../../constant/mode.ts";
 import { mergeFuns } from "../../utils/shared.ts";
 import { Transform } from "konva/lib/Util";
+import { lineLen } from "@/utils/math.ts";
 
 export const useViewer = installGlobalVar(
 	() => {
@@ -70,3 +71,42 @@ export const useViewerInvertTransformConfig = () => {
 	const transform = useViewerInvertTransform()
 	return computed(() => transform.value.decompose());
 }
+
+
+export const useUnitTransform = installGlobalVar(() => {
+	const transform = useViewerTransform()
+	const invTransform = useViewerInvertTransform()
+	let pixelCache: Record<string, number> = {}
+	let realCache: Record<string, number> = {}
+	watch(transform, () => {
+		pixelCache = {}
+	})
+	watch(invTransform, () => {
+		realCache = {}
+	})
+
+	return {
+		getPixel(real: number) {
+			if (real in pixelCache) {
+				return pixelCache[real];
+			} else {
+				const len = lineLen(
+					invTransform.value.point({x: real, y: 0}),
+					invTransform.value.point({x: 0, y: 0})
+				)
+				return pixelCache[real] = len
+			}
+		},
+		getReal(pixel: number) {
+			if (pixel in realCache) {
+				return realCache[pixel];
+			} else {
+				const len = lineLen(
+					transform.value.point({x: pixel, y: 0}),
+					transform.value.point({x: 0, y: 0})
+				)
+				return pixelCache[pixel] = len
+			}
+		}
+	}
+})

+ 21 - 10
src/core/propertys/util.ts

@@ -1,19 +1,30 @@
 import { Ref } from "vue";
 import { PropertyDescribes } from ".";
 
-export const generateDescribes = (data: Ref<any>, defaultData: any, describes: PropertyDescribes) => {
-  for (const key in describes) {
-    if (!(key in data) && (key in defaultData) && !('value' in describes[key])) {
-      Object.defineProperty(describes[key], 'value', {
+export const generateDescribes = (
+  data: Ref<any>,
+  defaultData: any,
+  describesRaw: PropertyDescribes
+) => {
+  const describes = { ...describesRaw };
+  for (const key in describesRaw) {
+    const exisDef = defaultData && key in defaultData;
+    if (!(key in data.value) && !exisDef ) {
+      delete describes[key];
+      continue;
+    }
+
+    if (!("value" in describes[key])) {
+      Object.defineProperty(describes[key], "value", {
         get() {
-          return data.value[key] || defaultData[key]
+          return key in data.value ? data.value[key] : defaultData[key];
         },
         set(val) {
-          data.value[key] = val
-          return true
-        }
-      }) 
+          data.value[key] = val;
+          return true;
+        },
+      });
     }
   }
   return describes;
-}
+};

+ 1 - 1
src/core/renderer/draw-group.vue

@@ -106,7 +106,7 @@ if (gInteractive!) {
         }
       }
 
-      // 消费结束,发送添加完毕数据,未消费的则取
+      // 消费结束,发送添加完毕数据,未消费的则取
       store.addItems(props.type, tempItems.value);
       tempItems.value = [];
     },

+ 4 - 3
src/core/renderer/group.vue

@@ -1,11 +1,12 @@
 <template>
+  <!-- @add="(value: any) => store.addItem(type, value)" -->
   <ShapeComponent
     :data="item"
     v-for="item in items"
     :key="item.id"
-    @update="(value) => store.setItem(type, { id: item.id, value })"
-    @add="(value: any) => store.addItem(type, value)"
-    @del="() => store.delItem(type, item.id)"
+    @updateShape="(value: any) => store.setItem(type, { id: item.id, value })"
+    @addShape="(value: any) => store.addItem(type, value)"
+    @delShape="() => store.delItem(type, item.id)"
   />
 </template>
 

+ 164 - 163
src/core/store/init.ts

@@ -1,134 +1,151 @@
 export const initData = {
-  // "image": [
-  // 	// {
-  // 	// 	"url": "/src/assets/WX20241031-111850.png",
-  // 	// 	"width": 1,
-  // 	// 	"height": 1,
-  // 	// 	"x": 594.5,
-  // 	// 	"y": 436
-  // 	// }
-  // ],
+  image: [
+    {
+      id: "-10",
+      createTime: 1,
+      zIndex: -194,
+      url: "/WX20241213-205427@2x.png",
+      mat: [
+        0.11196146764392557, 0, 0, 0.11196146764392542, 115.58984375,
+        243.9711189430529,
+      ],
+    },
+  ],
   rectangle: [
-    {"id":"0","createTime":1,"zIndex":0,"attitude":[0.49671531254033047,0.6435180082478934,-0.5194746707265486,0.4009693903193004,793.3046144557984,-304.8623779525318],"points":[{"x":1192.8999748096812,"y":353.7459910496289},{"x":1280.3121683458312,"y":466.9925917901596},{"x":1179.2844908791794,"y":544.9733067738582},{"x":1091.8722973430301,"y":431.7267060333284}]},
+    {
+      id: "0",
+      createTime: 1,
+      zIndex: 0,
+      attitude: [
+        8.251736904074757e-17, 1.347610904600402, -1.476797504076872,
+        -1.5348915434824124e-13, 1569.5827159467738, -794.9590755343082,
+      ],
+      points: [
+        { x: 1363.3800961316804, y: 483.5732802091982 },
+        { x: 1363.380096131679, y: 711.9249144776211 },
+        { x: 1088.6068794194352, y: 711.9249144775904 },
+        { x: 1088.606879419436, y: 483.57328020916987 },
+      ],
+    },
     {
       id: "1",
       createTime: 0,
       zIndex: 0,
-      attitude: [1, 0, 0, 1, 0, 0],
+      attitude: [
+        1.2148907961465611, -1.1842248053033169, 0.9286933259554234,
+        0.9527422235147321, -403.84065282732183, 1441.7093493883922,
+      ],
       fill: "red",
       points: [
+        { x: 662.2040392640735, y: 673.3008526997271 },
+        { x: 856.8446856698328, y: 483.5732802091982 },
+        { x: 1021.2743397890409, y: 652.260908190292 },
+        { x: 826.633693383281, y: 841.9884806808195 },
+      ],
+    },
+  ],
+  triangle: [
+    {
+      createTime: 3,
+      zIndex: 0,
+      id: "3",
+      attitude: [1, 0, 0, 1, 0, 0],
+      points: [
         {
-          x: 758.00390625,
-          y: 138.04296875,
+          x: 115.58984375,
+          y: 626.625,
         },
         {
-          x: 933.984375,
-          y: 138.04296875,
+          x: 229.76953125,
+          y: 495.828125,
         },
         {
-          x: 933.984375,
-          y: 332.5234375,
+          x: 343.94921875,
+          y: 626.625,
+        },
+      ],
+    },
+  ],
+  polygon: [
+    {
+      createTime: 3,
+      zIndex: 0,
+      id: "33",
+      attitude: [1, 0, 0, 1, 0, 0],
+      points: [
+        {
+          x: 416.5390625,
+          y: 354.78125,
+        },
+        {
+          x: 715.4765625,
+          y: 370.1796875,
+        },
+        {
+          x: 686.734375,
+          y: 538.16796875,
         },
         {
-          x: 758.00390625,
-          y: 332.5234375,
+          x: 400.56640625,
+          y: 531.5390625,
+        },
+      ],
+    },
+  ],
+  line: [
+    {
+      createTime: 3,
+      zIndex: 0,
+      id: "333",
+      attitude: [1, 0, 0, 1, 0, 0],
+      points: [
+        {
+          x: 327.5546875,
+          y: 71.54296875,
+        },
+        {
+          x: 623.5703125,
+          y: 74.07421875,
+        },
+        {
+          x: 602.90234375,
+          y: 228.5,
+        },
+        {
+          x: 311.03515625,
+          y: 263.71484375,
+        },
+      ],
+    },
+  ],
+  arrow: [
+    {
+      id: "8",
+      createTime: 1,
+      zIndex: 0,
+      attitude: [1, 0, 0, 1, -85.9609375, 129.58203125],
+      points: [
+        { x: 879.94140625, y: 265.87109375 },
+        { x: 662.28515625, y: 381.26171875 },
+      ],
+    },
+    {
+      id: "9",
+      createTime: 1,
+      zIndex: 0,
+      attitude: [1, 0, 0, 1, 0, 0],
+      points: [
+        {
+          x: 756.35546875,
+          y: 94.78125,
+        },
+        {
+          x: 879.94140625,
+          y: 265.87109375,
         },
       ],
     },
   ],
-  // "triangle": [
-  // 	{
-  // 		"attitude": [1, 0, 0, 1, 0, 0],
-  // 		"points": [
-  // 			{
-  // 				"x": 115.58984375,
-  // 				"y": 626.625
-  // 			},
-  // 			{
-  // 				"x": 229.76953125,
-  // 				"y": 495.828125
-  // 			},
-  // 			{
-  // 				"x": 343.94921875,
-  // 				"y": 626.625
-  // 			}
-  // 		]
-  // 	}
-  // ],
-  // "polygon": [
-  // 	{
-  // 		"attitude": [1, 0, 0, 1, 0, 0],
-  // 		"points": [
-  // 			{
-  // 				"x": 416.5390625,
-  // 				"y": 354.78125
-  // 			},
-  // 			{
-  // 				"x": 715.4765625,
-  // 				"y": 370.1796875
-  // 			},
-  // 			{
-  // 				"x": 686.734375,
-  // 				"y": 538.16796875
-  // 			},
-  // 			{
-  // 				"x": 400.56640625,
-  // 				"y": 531.5390625
-  // 			}
-  // 		]
-  // 	}
-  // ],
-  // "line": [
-  // 	{
-  // 		attitude: [1, 0, 0, 1, 0, 0],
-  // 		"points": [
-  // 			{
-  // 				"x": 327.5546875,
-  // 				"y": 71.54296875
-  // 			},
-  // 			{
-  // 				"x": 623.5703125,
-  // 				"y": 74.07421875
-  // 			},
-  // 			{
-  // 				"x": 602.90234375,
-  // 				"y": 228.5
-  // 			},
-  // 			{
-  // 				"x": 311.03515625,
-  // 				"y": 263.71484375
-  // 			}
-  // 		]
-  // 	}
-  // ],
-  // "arrow": [
-  // 	{
-  // 		attitude: [1, 0, 0, 1, 0, 0],
-  // 		"points": [
-  // 			{
-  // 				"x": 962.1484375,
-  // 				"y": 129.10546875
-  // 			},
-  // 			{
-  // 				"x": 744.4921875,
-  // 				"y": 244.49609375
-  // 			}
-  // 		]
-  // 	},
-  // 	{
-  // 		attitude: [1, 0, 0, 1, 0, 0],
-  // 		"points": [
-  // 			{
-  // 				"x": 756.35546875,
-  // 				"y": 94.78125
-  // 			},
-  // 			{
-  // 				"x": 879.94140625,
-  // 				"y": 265.87109375
-  // 			}
-  // 		]
-  // 	}
-  // ],
   // "circle": [
   // 	{
   // 		"x": 1372.22265625,
@@ -136,57 +153,41 @@ export const initData = {
   // 		"radius": 84.72265625
   // 	}
   // ],
-  // "icon": [
-  // 	{
-  // 		"url": "/example/fuse//assets/icons/vue.svg",
-  // 		"mat": [
-  // 			1,
-  // 			0,
-  // 			0,
-  // 			1,
-  // 			791.984375,
-  // 			464.45703125
-  // 		]
-  // 	},
-  // 	{
-  // 		"url": "/example/fuse//assets/icons/vue.svg",
-  // 		"mat": [
-  // 			1,
-  // 			0,
-  // 			0,
-  // 			1,
-  // 			1072.265625,
-  // 			467.41796875
-  // 		]
-  // 	},
-  // 	{
-  // 		"url": "/example/fuse/assets/icons/vue.svg",
-  // 		"mat": [
-  // 			1,
-  // 			0,
-  // 			0,
-  // 			1,
-  // 			1281.1015625,
-  // 			448.33984375
-  // 		]
-  // 	},
-  // 	{
-  // 		"url": "/example/fuse/assets/icons/BedsideCupboard.svg",
-  // 		"width": 300,
-  // 		"height": 300,
-  // 		"stroke": "red",
-  // 		"strokeWidth": 1,
-  // 		"strokeScaleEnabled": false,
-  // 		"mat": [
-  // 			1,
-  // 			0,
-  // 			0,
-  // 			1,
-  // 			954.53515625,
-  // 			519.8671875
-  // 		]
-  // 	}
-  // ],
+  icon: [
+    {
+      createTime: 3,
+      zIndex: 0,
+      id: "3333",
+      url: "/example/fuse//assets/icons/vue.svg",
+      mat: [1, 0, 0, 1, 791.984375, 464.45703125],
+    },
+    {
+      createTime: 3,
+      zIndex: 0,
+      id: "3331",
+      url: "/example/fuse//assets/icons/vue.svg",
+      mat: [1, 0, 0, 1, 1072.265625, 467.41796875],
+    },
+    {
+      createTime: 3,
+      zIndex: 0,
+      id: "3323",
+      url: "/example/fuse/assets/icons/vue.svg",
+      mat: [1, 0, 0, 1, 1281.1015625, 448.33984375],
+    },
+    {
+      createTime: 3,
+      zIndex: 0,
+      id: "3313",
+      url: "/example/fuse/assets/icons/BedsideCupboard.svg",
+      width: 300,
+      height: 300,
+      stroke: "red",
+      strokeWidth: 1,
+      strokeScaleEnabled: false,
+      mat: [1, 0, 0, 1, 954.53515625, 519.8671875],
+    },
+  ],
   // "text": [
   // 	{
   // 		"fill": "#000",

+ 62 - 0
src/core/transformer.ts

@@ -0,0 +1,62 @@
+import { Pos } from '@/utils/math';
+import Konva from 'konva';
+import { Transformer as BaseTransformer } from 'konva/lib/shapes/Transformer'
+import { Transform } from 'konva/lib/Util';
+
+
+const MAX_SAFE_INTEGER = 100000000;
+export class Transformer extends BaseTransformer {
+  __getNodeRect() {const node = this.getNode();
+    if (!node) {
+        return {
+            x: -MAX_SAFE_INTEGER,
+            y: -MAX_SAFE_INTEGER,
+            width: 0,
+            height: 0,
+            rotation: 0,
+        };
+    }
+    const totalPoints: Pos[] = [];
+    this.nodes().map((node) => {
+        const box = node.getClientRect({
+            skipTransform: true,
+            skipShadow: true,
+            skipStroke: this.ignoreStroke(),
+        });
+        const points = [
+            { x: box.x, y: box.y },
+            { x: box.x + box.width, y: box.y },
+            { x: box.x + box.width, y: box.y + box.height },
+            { x: box.x, y: box.y + box.height },
+        ];
+        const trans = node.getAbsoluteTransform();
+        points.forEach(function (point) {
+            const transformed = trans.point(point);
+            totalPoints.push(transformed);
+        });
+    });
+    const tr = new Transform();
+    tr.rotate(-Konva.getAngle(this.rotation()));
+    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+    totalPoints.forEach(function (point) {
+        const transformed = tr.point(point);
+        if (minX === undefined) {
+            minX = maxX = transformed.x;
+            minY = maxY = transformed.y;
+        }
+        minX = Math.min(minX, transformed.x);
+        minY = Math.min(minY, transformed.y);
+        maxX = Math.max(maxX, transformed.x);
+        maxY = Math.max(maxY, transformed.y);
+    });
+    tr.invert();
+    const p = tr.point({ x: minX, y: minY });
+    return {
+        x: p.x,
+        y: p.y,
+        width: maxX - minX,
+        height: maxY - minY,
+        rotation: Konva.getAngle(this.rotation()),
+    };
+  }
+}

+ 93 - 44
src/utils/math.ts

@@ -4,9 +4,20 @@ import { round } from "./shared.ts";
 
 export type Pos = { x: number; y: number };
 
-export const vector = (pos: Pos) => new Vector2(pos.x, pos.y);
+export const vector = (pos: Pos = {x: 0, y: 0}): Vector2 => {
+  return new Vector2(pos.x, pos.y);
+  // if (pos instanceof Vector2) {
+  //   return pos;
+  // } else {
+  //   return new Vector2(pos.x, pos.y);
+  // }
+};
 export const lVector = (line: Pos[]) => line.map(vector);
 
+export const zeroEq = (n: number) => Math.abs(n) < 0.0001;
+export const numEq = (p1: number, p2: number) => zeroEq(p1 - p2);
+export const vEq = (v1: Pos, v2: Pos) => numEq(v1.x, v2.x) && numEq(v2.y, v2.y);
+
 export const vsBound = (positions: Pos[]) => {
   const box = new Box2();
   box.setFromPoints(positions.map(vector));
@@ -29,8 +40,16 @@ const epsilon = 1e-6; // 误差范围
  * @param p2 点2
  * @returns 是否相等
  */
-export const eqPoint = (p1: Pos, p2: Pos) =>
-  vector(p1).distanceTo(p2) < epsilon;
+export const eqPoint = vEq
+
+/**
+ * 方向是否相同
+ * @param p1 点1
+ * @param p2 点2
+ * @returns 是否相等
+ */
+export const eqNGDire = (p1: Pos, p2: Pos) =>
+  eqPoint(p1, p2) || eqPoint(p1, vector(p2).multiplyScalar(-1))
 
 /**
  * 获取两点距离
@@ -116,6 +135,17 @@ export const line2IncludedAngle = (line1: Pos[], line2: Pos[]) =>
   vector2IncludedAngle(lineVector(line1), lineVector(line2));
 
 /**
+ * 获取向量与X正轴角度
+ * @param v 向量
+ * @returns 夹角弧度
+ */
+const nXAxis = vector({ x: 1, y: 0 });
+export const vectorAngle = (v: Pos) => {
+  const start = vector(v);
+  return start.angleTo(nXAxis);
+};
+
+/**
  * 获取线段与方向的夹角弧度
  * @param line 线段
  * @param dire 方向
@@ -181,6 +211,9 @@ export const isLineIntersect = (l1: Pos[], l2: Pos[]) => {
   }
 };
 
+export const vectorParallel = (dire1: Pos, dire2: Pos) =>
+  zeroEq(vector(dire1).cross(dire2));
+
 export const lineParallelRelationship = (l1: Pos[], l2: Pos[]) => {
   const dire1 = lineVector(l1);
   const dire2 = lineVector(l2);
@@ -246,6 +279,15 @@ export const lineRelationship = (l1: Pos[], l2: Pos[]) => {
   }
 };
 
+export const createLine = (p: Pos, v: Pos, l?: number) => {
+  const line = [p]
+  if (l) {
+    v = vector(v).multiplyScalar(l)
+  }
+  line[1] = vector(line[0]).add(v)
+  return line
+}
+
 /**
  * 获取两线段交点,可延长相交
  * @param l1 线段1
@@ -273,7 +315,11 @@ export const lineIntersection = (l1: Pos[], l2: Pos[]) => {
   // 求解参数t和s
   const t = (d * e - b * f) / (a * d - b * c);
   // 计算交点坐标
-  return line1Start.clone().add(dir1.clone().multiplyScalar(t)).toArray();
+  const p = line1Start.clone().add(dir1.clone().multiplyScalar(t));
+
+  if (isNaN(p.x) || !isFinite(p.x) || isNaN(p.y) || !isFinite(p.y)) return null;
+
+  return p;
 };
 
 /**
@@ -361,49 +407,52 @@ export const joinPoint = (p1: Pos, p2: Pos, rad: number) => {
 };
 
 /**
- * 计算经过旋转的点位,要缩放多少才能到达目标
+ * 要缩放多少才能到达目标
  * @param origin 缩放原点
- * @param current 当前点位
- * @param target 目标点位 x轴 或者y轴
- * @param rotateTransfom 当前点位经过了的旋转
- * @param type 缩放类型  x | y | xy
+ * @param scaleDirection 缩放方向
+ * @param p1 当前点位
+ * @param p2 目标点位
  * @returns
  */
-type CalcScaleFactorProps = {
-  origin: Pos;
-  current: Pos;
-  target: Partial<Pos>;
-  rotateTransfom?: Transform;
-  type?: "x" | "y" | "xy";
-};
-export const calcScaleFactor = ({
-  origin,
-  current,
-  target,
-  rotateTransfom,
-  type = "xy",
-}: CalcScaleFactorProps): Partial<Pos> | null => {
-  // 将原点和目标点转换到未转转的坐标系
-  let norTarget = { x: 0, y: 0, ...target }
-  if (rotateTransfom) {
-    const invRotate = rotateTransfom.copy().invert();
-    current = invRotate.point(current);
-    norTarget = invRotate.point(norTarget);
+export function calculateScaleFactor(
+  origin: Pos,
+  scaleDirection: Pos,
+  p1: Pos,
+  p2: Pos
+) {
+  const op1 = vector(p1).sub(origin);
+  const op2 = vector(p2).sub(origin);
+  const xZero = zeroEq(op1.x)
+  const yZero = zeroEq(op1.y)
+
+  if (zeroEq(op1.x) || zeroEq(op2.y)) return;
+  if (zeroEq(scaleDirection.x)) {
+    if (zeroEq(p2.x - p1.x)) {
+      return zeroEq(op1.y - op2.y) ? 1 : yZero ? null : op2.y / op1.y;
+    } else {
+      return;
+    }
   }
+  if (zeroEq(scaleDirection.y)) {
+    if (zeroEq(p2.y - p1.y)) {
+      return zeroEq(op1.x - op2.x) ? 1 : xZero ? null : op2.x / op1.x;
+    } else {
+      return;
+    }
+  }
+  if (xZero && yZero) {
+    return null;
+  }
+  const xScaleFactor = op2.x / (op1.x * scaleDirection.x);
+  const yScaleFactor = op2.y / (op1.y * scaleDirection.y);
 
-  const oc = vector(origin).sub(current);
-  const ot = vector(origin).sub(norTarget);
-  if (type === "x") {
-    if (oc.x === 0) return null;
-    return { x: ot.x / oc.x };
-  } else if (type === "y") {
-    if (oc.y === 0) return null;
-    return { y: ot.y / oc.y };
-  } else {
-    if (oc.x === 0 || oc.y === 0) return null;
-    return {
-      x: ot.x / oc.x,
-      y: ot.y / oc.y,
-    };
+  if (xZero) {
+    return yScaleFactor
+  } else if (yZero) {
+    return xScaleFactor
   }
-};
+  console.log(xScaleFactor - yScaleFactor)
+  if (zeroEq(xScaleFactor - yScaleFactor)) {
+    return xScaleFactor;
+  }
+}

+ 89 - 74
src/utils/resource.ts

@@ -1,85 +1,100 @@
+import { reactive } from "vue";
 
-
-let imageCache: Record<string, Promise<HTMLImageElement>>
+let imageCache: Record<string, Promise<HTMLImageElement>>;
+export let imageInfo: Record<string, Record<'width' | 'height', number>> = reactive({})
 export let getImage = (url: string): Promise<HTMLImageElement> => {
-	imageCache = {};
-	getImage = (url: string) => {
-		if (url in imageCache) {
-			return imageCache[url];
-		} else {
-			return imageCache[url] = new Promise((resolve, reject) => {
-				const image = new Image()
-				image.onload = () => {
-					resolve(image);
-				}
-				image.onerror = (e) => {
-					console.error(e)
-					reject(e);
-				}
-				image.src = url
-			})
-		}
-	}
-	return getImage(url)
-}
+  imageCache = {};
+  getImage = (url: string) => {
+    if (url in imageCache) {
+      return imageCache[url];
+    } else {
+      return (imageCache[url] = new Promise((resolve, reject) => {
+        const image = new Image();
+        image.onload = () => {
+          resolve(image);
+					imageInfo[url] = {
+						width: image.width,
+						height: image.height
+					}
+        };
+        image.onerror = (e) => {
+          reject(e);
+        };
+        image.src = url;
+      }));
+    }
+  };
+  return getImage(url);
+};
 
-let svgContentCache: Record<string, Promise<string>>
+let svgContentCache: Record<string, Promise<string>>;
 export let getSvgContent = (url: string): Promise<string> => {
-	svgContentCache = {}
-	getSvgContent = async (url: string) => {
-		if (url in svgContentCache) {
-			return svgContentCache[url]
-		} else {
-			const res = await fetch(url)
-			return (svgContentCache[url] = res.text())
-		}
-	}
-	return getSvgContent(url);
-}
+  svgContentCache = {};
+  getSvgContent = async (url: string) => {
+    if (url in svgContentCache) {
+      return svgContentCache[url];
+    } else {
+      const res = await fetch(url);
+      return (svgContentCache[url] = res.text());
+    }
+  };
+  return getSvgContent(url);
+};
 
-export type SVGPath ={ fill?: string; stroke?: string; strokeWidth?: number; data: string }
-export type SVGParseResult = { paths: SVGPath[], width: number; height: number, x: number, y: number }
-let svgParseCache: Record<string, SVGParseResult>
+export type SVGPath = {
+  fill?: string;
+  stroke?: string;
+  strokeWidth?: number;
+  data: string;
+};
+export type SVGParseResult = {
+  paths: SVGPath[];
+  width: number;
+  height: number;
+  x: number;
+  y: number;
+};
+let svgParseCache: Record<string, SVGParseResult>;
 let helpDOM: HTMLDivElement;
 export let parseSvgContent = (svgContent: string): SVGParseResult => {
-	svgParseCache = {}
-	helpDOM = document.createElement("div");
-	helpDOM.style.position = "absolute";
-	helpDOM.style.left = "-99999px";
-	helpDOM.style.top = "-99999px";
+  svgParseCache = {};
+  helpDOM = document.createElement("div");
+  helpDOM.style.position = "absolute";
+  helpDOM.style.left = "-99999px";
+  helpDOM.style.top = "-99999px";
 
-	parseSvgContent = (svgContent: string) => {
-		if (svgContent in svgParseCache) return svgParseCache[svgContent];
-		helpDOM.innerHTML = svgContent;
-		document.body.appendChild(helpDOM)
+  parseSvgContent = (svgContent: string) => {
+    if (svgContent in svgParseCache) return svgParseCache[svgContent];
+    helpDOM.innerHTML = svgContent;
+    document.body.appendChild(helpDOM);
 
-		let width = 0
-		let height = 0
-		let x = Number.MAX_VALUE
-		let y = Number.MAX_VALUE
-		const svgPaths = Array.from(helpDOM.querySelectorAll("path"));
-		const paths = svgPaths.map((path) => {
-			const box = path.getBBox()
-			x = Math.min(box.x, x)
-			y = Math.min(box.y, y)
-			width = Math.max(width, box.width)
-			height = Math.max(height, box.height)
+    let width = 0;
+    let height = 0;
+    let x = Number.MAX_VALUE;
+    let y = Number.MAX_VALUE;
+    const svgPaths = Array.from(helpDOM.querySelectorAll("path"));
+    const paths = svgPaths.map((path) => {
+      const box = path.getBBox();
+      x = Math.min(box.x, x);
+      y = Math.min(box.y, y);
+      width = Math.max(width, box.width);
+      height = Math.max(height, box.height);
 
-			const fill = path.getAttribute("fill")!;
-			const data = path.getAttribute("d")!;
-			const stroke = path.getAttribute("stroke")!;
-			const strokeWidth = path.getAttribute("stroke-width")!;
-			return {
-				fill,
-				data,
-				stroke,
-				strokeWidth: (strokeWidth && Number(strokeWidth)) || 1,
-			};
-		});
+      const fill = path.getAttribute("fill")!;
+      const data = path.getAttribute("d")!;
+      const stroke = path.getAttribute("stroke")!;
+      const strokeWidth = path.getAttribute("stroke-width")!;
+      return {
+        fill,
+        data,
+        stroke,
+        strokeWidth: (strokeWidth && Number(strokeWidth)) || 1,
+      };
+    });
 
-		helpDOM.innerHTML = ''
-		document.body.removeChild(helpDOM)
-		return (svgParseCache[svgContent] = { paths, width, height, x, y });
-	}
-	return parseSvgContent(svgContent)
-}
+    helpDOM.innerHTML = "";
+    document.body.removeChild(helpDOM);
+    return (svgParseCache[svgContent] = { paths, width, height, x, y });
+  };
+  return parseSvgContent(svgContent);
+};

+ 122 - 105
src/utils/shared.ts

@@ -1,5 +1,5 @@
 import { Pos } from "@/utils/math.ts";
-import { v4 as uuid } from 'uuid'
+import { v4 as uuid } from "uuid";
 
 /**
  * 四舍五入
@@ -8,8 +8,8 @@ import { v4 as uuid } from 'uuid'
  * @returns
  */
 export const round = (num: number, b: number = 2) => {
-	const scale = Math.pow(10, b);
-	return Math.round(num * scale) / scale;
+  const scale = Math.pow(10, b);
+  return Math.round(num * scale) / scale;
 };
 
 /**
@@ -27,7 +27,7 @@ export const rangMod = (num: number, mod: number) => ((num % mod) + mod) % mod;
  * @returns
  */
 export const warpIndexOf = (arr: number[], warp: number, val: number) =>
-	arr.findIndex((num) => val >= num - warp && val <= num + warp);
+  arr.findIndex((num) => val >= num - warp && val <= num + warp);
 
 /**
  * 多个函数合并成一个函数
@@ -35,15 +35,15 @@ export const warpIndexOf = (arr: number[], warp: number, val: number) =>
  * @returns
  */
 export const mergeFuns = (...fns: (() => void)[] | (() => void)[][]) => {
-	return () => {
-		fns.forEach((fn) => {
-			if (Array.isArray(fn)) {
-				fn.forEach((f) => f());
-			} else {
-				fn();
-			}
-		});
-	};
+  return () => {
+    fns.forEach((fn) => {
+      if (Array.isArray(fn)) {
+        fn.forEach((f) => f());
+      } else {
+        fn();
+      }
+    });
+  };
 };
 
 /**
@@ -52,63 +52,63 @@ export const mergeFuns = (...fns: (() => void)[] | (() => void)[][]) => {
  * @returns
  */
 export const toRawType = (value: unknown): string =>
-	Object.prototype.toString.call(value).slice(8, -1);
+  Object.prototype.toString.call(value).slice(8, -1);
 
 // 是否修改
 const _inRevise = (raw1: any, raw2: any, readly: Set<[any, any]>): boolean => {
-	if (raw1 === raw2) return false;
-
-	const rawType1 = toRawType(raw1);
-	const rawType2 = toRawType(raw2);
-
-	if (rawType1 !== rawType2) {
-		return true;
-	} else if (
-		rawType1 === "String" ||
-		rawType1 === "Number" ||
-		rawType1 === "Boolean"
-	) {
-		if (rawType1 === "Number" && isNaN(raw1) && isNaN(raw2)) {
-			return false;
-		} else {
-			return raw1 !== raw2;
-		}
-	}
-
-	const rawsArray = Array.from(readly.values());
-	for (const raws of rawsArray) {
-		if (raws.includes(raw1) && raws.includes(raw2)) {
-			return false;
-		}
-	}
-	readly.add([raw1, raw2]);
-
-	if (rawType1 === "Array") {
-		return (
-			raw1.length !== raw2.length ||
-			raw1.some((item1: any, i: number) => _inRevise(item1, raw2[i], readly))
-		);
-	} else if (rawType1 === "Object") {
-		const rawKeys1 = Object.keys(raw1).sort();
-		const rawKeys2 = Object.keys(raw2).sort();
-
-		return (
-			_inRevise(rawKeys1, rawKeys2, readly) ||
-			rawKeys1.some((key) => _inRevise(raw1[key], raw2[key], readly))
-		);
-	} else if (rawType1 === "Map") {
-		const rawKeys1 = Array.from(raw1.keys()).sort();
-		const rawKeys2 = Array.from(raw2.keys()).sort();
-
-		return (
-			_inRevise(rawKeys1, rawKeys2, readly) ||
-			rawKeys1.some((key) => _inRevise(raw1.get(key), raw2.get(key), readly))
-		);
-	} else if (rawType1 === "Set") {
-		return inRevise(Array.from(raw1.values()), Array.from(raw2.values()));
-	} else {
-		return raw1 !== raw2;
-	}
+  if (raw1 === raw2) return false;
+
+  const rawType1 = toRawType(raw1);
+  const rawType2 = toRawType(raw2);
+
+  if (rawType1 !== rawType2) {
+    return true;
+  } else if (
+    rawType1 === "String" ||
+    rawType1 === "Number" ||
+    rawType1 === "Boolean"
+  ) {
+    if (rawType1 === "Number" && isNaN(raw1) && isNaN(raw2)) {
+      return false;
+    } else {
+      return raw1 !== raw2;
+    }
+  }
+
+  const rawsArray = Array.from(readly.values());
+  for (const raws of rawsArray) {
+    if (raws.includes(raw1) && raws.includes(raw2)) {
+      return false;
+    }
+  }
+  readly.add([raw1, raw2]);
+
+  if (rawType1 === "Array") {
+    return (
+      raw1.length !== raw2.length ||
+      raw1.some((item1: any, i: number) => _inRevise(item1, raw2[i], readly))
+    );
+  } else if (rawType1 === "Object") {
+    const rawKeys1 = Object.keys(raw1).sort();
+    const rawKeys2 = Object.keys(raw2).sort();
+
+    return (
+      _inRevise(rawKeys1, rawKeys2, readly) ||
+      rawKeys1.some((key) => _inRevise(raw1[key], raw2[key], readly))
+    );
+  } else if (rawType1 === "Map") {
+    const rawKeys1 = Array.from(raw1.keys()).sort();
+    const rawKeys2 = Array.from(raw2.keys()).sort();
+
+    return (
+      _inRevise(rawKeys1, rawKeys2, readly) ||
+      rawKeys1.some((key) => _inRevise(raw1.get(key), raw2.get(key), readly))
+    );
+  } else if (rawType1 === "Set") {
+    return inRevise(Array.from(raw1.values()), Array.from(raw2.values()));
+  } else {
+    return raw1 !== raw2;
+  }
 };
 
 /**
@@ -117,20 +117,21 @@ const _inRevise = (raw1: any, raw2: any, readly: Set<[any, any]>): boolean => {
  * @param raw2
  * @returns
  */
-export const inRevise = (raw1: any, raw2: any) => _inRevise(raw1, raw2, new Set());
+export const inRevise = (raw1: any, raw2: any) =>
+  _inRevise(raw1, raw2, new Set());
 
 // 防抖
 export const debounce = <T extends (...args: any) => any>(
-	fn: T,
-	delay: number = 160
+  fn: T,
+  delay: number = 160
 ) => {
-	let timeout: any;
-	return function (...args: Parameters<T>) {
-		clearTimeout(timeout);
-		timeout = setTimeout(() => {
-			fn.apply(null, args);
-		}, delay);
-	};
+  let timeout: any;
+  return function (...args: Parameters<T>) {
+    clearTimeout(timeout);
+    timeout = setTimeout(() => {
+      fn.apply(null, args);
+    }, delay);
+  };
 };
 
 /**
@@ -140,38 +141,54 @@ export const debounce = <T extends (...args: any) => any>(
  * @returns
  */
 export const getChangePart = <T>(newIds: T[], oldIds: T[] = []) => {
-	const addPort = newIds.filter((newId) => !oldIds.includes(newId));
-	const delPort = oldIds.filter((oldId) => !newIds.includes(oldId));
-	const holdPort = oldIds.filter((oldId) => newIds.includes(oldId));
+  const addPort = newIds.filter((newId) => !oldIds.includes(newId));
+  const delPort = oldIds.filter((oldId) => !newIds.includes(oldId));
+  const holdPort = oldIds.filter((oldId) => newIds.includes(oldId));
 
-	return {addPort, delPort, holdPort};
+  return { addPort, delPort, holdPort };
 };
 
-export const flatPositions = (positions: Pos[]) => positions.flatMap(p => [p.x, p.y]);
+export const flatPositions = (positions: Pos[]) =>
+  positions.flatMap((p) => [p.x, p.y]);
 
 export const flatToPositions = (coords: number[]) => {
-	const positions: Pos[] = []
-	for (let i = 0; i < coords.length; i+=2) {
-		positions.push({
-			x: coords[i],
-			y: coords[i + 1],
-		})
-	}
-	return positions
-}
-
-export const onlyId = () => uuid()
+  const positions: Pos[] = [];
+  for (let i = 0; i < coords.length; i += 2) {
+    positions.push({
+      x: coords[i],
+      y: coords[i + 1],
+    });
+  }
+  return positions;
+};
+
+export const onlyId = () => uuid();
 
 export const startAnimation = (update: () => void) => {
-	let isStop = false
-	const animation = () => {
-		requestAnimationFrame(() => {
-			if (!isStop) {
-				update()
-				animation()
-			}
-		})
-	}
-	animation()
-	return () => isStop = true
-}
+  let isStop = false;
+  const animation = () => {
+    requestAnimationFrame(() => {
+      if (!isStop) {
+        update();
+        animation();
+      }
+    });
+  };
+  animation();
+  return () => (isStop = true);
+};
+
+export const arrayInsert = <T>(
+  array: T[],
+  item: T,
+  canInsert: (eItem: T, insertItem: T) => boolean
+) => {
+  let i = 0;
+  for (i = 0; i < array.length; i++) {
+    if (canInsert(array[i], item)) {
+      break;
+    }
+  }
+  array.splice(i, 0, item);
+  return array;
+};