Преглед на файлове

初始化画板公共服务

bill преди 9 месеца
ревизия
3d0bebc893
променени са 81 файла, в които са добавени 10326 реда и са изтрити 0 реда
  1. 24 0
      .gitignore
  2. 5 0
      README.md
  3. 24 0
      components.d.ts
  4. 13 0
      index.html
  5. 22 0
      konva-components.d.ts
  6. 4126 0
      package-lock.json
  7. 37 0
      package.json
  8. 1806 0
      pnpm-lock.yaml
  9. 1 0
      public/vite.svg
  10. 11 0
      src/App.vue
  11. BIN
      src/assets/WX20241031-111850.png
  12. 1 0
      src/assets/icons/BedsideCupboard.svg
  13. 1 0
      src/assets/icons/vue.svg
  14. 13 0
      src/draw/constant/help-style.ts
  15. 7 0
      src/draw/constant/mode.ts
  16. 134 0
      src/draw/core/Transformer.ts
  17. 11 0
      src/draw/core/components/arrow/arrow.vue
  18. 48 0
      src/draw/core/components/arrow/index.ts
  19. 11 0
      src/draw/core/components/circle/circle.vue
  20. 54 0
      src/draw/core/components/circle/index.ts
  21. 69 0
      src/draw/core/components/icon/icon.vue
  22. 57 0
      src/draw/core/components/icon/index.ts
  23. 48 0
      src/draw/core/components/image/image.vue
  24. 51 0
      src/draw/core/components/image/index.ts
  25. 50 0
      src/draw/core/components/index.ts
  26. 49 0
      src/draw/core/components/line/index.ts
  27. 11 0
      src/draw/core/components/line/line.vue
  28. 52 0
      src/draw/core/components/polygon/index.ts
  29. 11 0
      src/draw/core/components/polygon/polygon.vue
  30. 86 0
      src/draw/core/components/rectangle/index.ts
  31. 26 0
      src/draw/core/components/rectangle/rectangle.vue
  32. 8 0
      src/draw/core/components/rectangle/temp-rectangle.vue
  33. 29 0
      src/draw/core/components/share/point.vue
  34. 59 0
      src/draw/core/components/text/index.ts
  35. 50 0
      src/draw/core/components/text/text.vue
  36. 66 0
      src/draw/core/components/triangle/index.ts
  37. 8 0
      src/draw/core/components/triangle/temp-triangle.vue
  38. 52 0
      src/draw/core/components/triangle/triangle.vue
  39. 122 0
      src/draw/core/history.ts
  40. 109 0
      src/draw/core/hook/use-animation.ts
  41. 14 0
      src/draw/core/hook/use-automatic-data.ts
  42. 11 0
      src/draw/core/hook/use-coversion-position.ts
  43. 52 0
      src/draw/core/hook/use-event.ts
  44. 30 0
      src/draw/core/hook/use-expose.ts
  45. 104 0
      src/draw/core/hook/use-global-vars.ts
  46. 268 0
      src/draw/core/hook/use-interactive.ts
  47. 197 0
      src/draw/core/hook/use-mouse-status.ts
  48. 227 0
      src/draw/core/hook/use-transformer.ts
  49. 72 0
      src/draw/core/hook/use-viewer.ts
  50. 90 0
      src/draw/core/renderer/draw-group.vue
  51. 21 0
      src/draw/core/renderer/group.vue
  52. 62 0
      src/draw/core/renderer/renderer.vue
  53. 183 0
      src/draw/core/store/index.ts
  54. 132 0
      src/draw/core/viewer.ts
  55. 8 0
      src/draw/helper/deconstruction.d.ts
  56. 14 0
      src/draw/index.ts
  57. 36 0
      src/draw/utils/align-port.ts
  58. 37 0
      src/draw/utils/colors.ts
  59. 176 0
      src/draw/utils/event.ts
  60. 362 0
      src/draw/utils/math.ts
  61. 85 0
      src/draw/utils/resource.ts
  62. 59 0
      src/draw/utils/shape.ts
  63. 163 0
      src/draw/utils/shared.ts
  64. 13 0
      src/main.ts
  65. 3 0
      src/store/index.ts
  66. 8 0
      src/styles/element.scss
  67. 33 0
      src/styles/global.scss
  68. 83 0
      src/views/header/funds.ts
  69. 59 0
      src/views/header/header.vue
  70. 1 0
      src/views/header/index.ts
  71. 61 0
      src/views/home.vue
  72. 4 0
      src/views/slide/index.ts
  73. 101 0
      src/views/slide/menu.ts
  74. 22 0
      src/views/slide/slide-item.vue
  75. 53 0
      src/views/slide/slide.vue
  76. 26 0
      src/views/use-draw.ts
  77. 1 0
      src/vite-env.d.ts
  78. 27 0
      tsconfig.app.json
  79. 7 0
      tsconfig.json
  80. 23 0
      tsconfig.node.json
  81. 36 0
      vite.config.ts

+ 24 - 0
.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 5 - 0
README.md

@@ -0,0 +1,5 @@
+# Vue 3 + TypeScript + Vite
+
+This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
+
+Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

+ 24 - 0
components.d.ts

@@ -0,0 +1,24 @@
+/* eslint-disable */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+export {}
+
+/* prettier-ignore */
+declare module 'vue' {
+  export interface GlobalComponents {
+    AlideItem: typeof import('./src/components/alide-item.vue')['default']
+    Content: typeof import('./src/views/content/content.vue')['default']
+    ElButton: typeof import('element-plus/es')['ElButton']
+    ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
+    ElIcon: typeof import('element-plus/es')['ElIcon']
+    ElMenu: typeof import('element-plus/es')['ElMenu']
+    ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
+    ElMenuItemGroup: typeof import('element-plus/es')['ElMenuItemGroup']
+    ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
+    Header: typeof import('./src/views/header/header.vue')['default']
+    HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
+    Slide: typeof import('./src/views/slide/slide.vue')['default']
+    SlideItem: typeof import('./src/views/slide/slide-item.vue')['default']
+  }
+}

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Vite + Vue + TS</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 22 - 0
konva-components.d.ts

@@ -0,0 +1,22 @@
+/* eslint-disable */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+
+/* prettier-ignore */
+// declare module '@vue/runtime-core' {
+// 	export interface GlobalComponents {
+// 		VStage: typeof import('./src/components/alide-item.vue')['default']
+// 	}
+// }
+// // src/types/global-components.d.ts
+
+import { defineComponent } from 'vue'
+
+declare module '@vue/runtime-core' {
+	export interface GlobalComponents {
+		VStage: typeof defineComponent
+		AnotherGlobalComponent: typeof defineComponent
+		// 添加更多的全局组件类型
+	}
+}

Файловите разлики са ограничени, защото са твърде много
+ 4126 - 0
package-lock.json


+ 37 - 0
package.json

@@ -0,0 +1,37 @@
+{
+  "name": "drawing-board-service",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc -b && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@element-plus/icons-vue": "^2.3.1",
+    "@tweenjs/tween.js": "^25.0.0",
+    "@types/three": "^0.169.0",
+    "element-plus": "^2.8.6",
+    "konva": "^9.3.16",
+    "localforage": "^1.10.0",
+    "mitt": "^3.0.1",
+    "pinia": "^2.2.4",
+    "sass": "^1.80.4",
+    "stateshot": "^1.3.5",
+    "three": "^0.169.0",
+    "unplugin-auto-import": "^0.18.3",
+    "unplugin-element-plus": "^0.8.0",
+    "unplugin-vue-components": "^0.27.4",
+    "uuid": "^11.0.2",
+    "vue": "^3.5.12",
+    "vue-konva": "^3.1.2"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.1.4",
+    "sass-embedded": "^1.80.4",
+    "typescript": "~5.6.2",
+    "vite": "^5.4.9",
+    "vue-tsc": "^2.1.6"
+  }
+}

Файловите разлики са ограничени, защото са твърде много
+ 1806 - 0
pnpm-lock.yaml


Файловите разлики са ограничени, защото са твърде много
+ 1 - 0
public/vite.svg


+ 11 - 0
src/App.vue

@@ -0,0 +1,11 @@
+
+<template>
+	<ElConfigProvider :locale="zhCn">
+		<Home />
+	</ElConfigProvider>
+</template>
+
+<script setup lang="ts">
+import zhCn from 'element-plus/es/locale/lang/zh-cn.mjs'
+import Home from "./views/home.vue";
+</script>

BIN
src/assets/WX20241031-111850.png


Файловите разлики са ограничени, защото са твърде много
+ 1 - 0
src/assets/icons/BedsideCupboard.svg


+ 1 - 0
src/assets/icons/vue.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

+ 13 - 0
src/draw/constant/help-style.ts

@@ -0,0 +1,13 @@
+import { getMouseColors } from "../utils/colors"
+
+export const themeColor = '#D8000A'
+export const themeMouseColors = getMouseColors(themeColor)
+
+
+
+export const HelpPointStyle = {
+	radius: 10,
+	fill: 'red',
+	stroke: 'black',
+	strokeWidth: 4,
+}

+ 7 - 0
src/draw/constant/mode.ts

@@ -0,0 +1,7 @@
+export enum Mode {
+	add = 'add',
+	viewer = 'viewer',
+	readonly = 'readonly',
+	update = 'update',
+}
+

+ 134 - 0
src/draw/core/Transformer.ts

@@ -0,0 +1,134 @@
+import { Transformer as BaseTransformer, TransformerConfig } from "konva/lib/shapes/Transformer";
+import * as Util_1 from "konva/lib/Util";
+import * as Global_1 from "konva/lib/Global";
+import { KonvaEventObject } from "konva/lib/Node";
+
+export class Transformer extends BaseTransformer {
+  constructor(config?: TransformerConfig) {
+    super(config)
+  }
+
+  __getNodeRect(): { x: number; y: number; width: number; height: number; rotation: number; } {
+    return super.__getNodeRect()
+  }
+
+
+  _fitNodesInto(newAttrs: any, evt: KonvaEventObject<any>) {
+    const oldAttrs = this._getNodeRect();
+    const minSize = 1;
+    if (Util_1.Util._inRange(newAttrs.width, -this.padding() * 2 - minSize, minSize)) {
+        this.update();
+        return;
+    }
+    if (Util_1.Util._inRange(newAttrs.height, -this.padding() * 2 - minSize, minSize)) {
+        this.update();
+        return;
+    }
+    const t = new Util_1.Transform();
+    t.rotate(Global_1.Konva.getAngle(this.rotation()));
+    if (this._movingAnchorName &&
+        newAttrs.width < 0 &&
+        this._movingAnchorName.indexOf('left') >= 0) {
+        const offset = t.point({
+            x: -this.padding() * 2,
+            y: 0,
+        });
+        newAttrs.x += offset.x;
+        newAttrs.y += offset.y;
+        newAttrs.width += this.padding() * 2;
+        this._movingAnchorName = this._movingAnchorName.replace('left', 'right');
+        this._anchorDragOffset.x -= offset.x;
+        this._anchorDragOffset.y -= offset.y;
+    }
+    else if (this._movingAnchorName &&
+        newAttrs.width < 0 &&
+        this._movingAnchorName.indexOf('right') >= 0) {
+        const offset = t.point({
+            x: this.padding() * 2,
+            y: 0,
+        });
+        this._movingAnchorName = this._movingAnchorName.replace('right', 'left');
+        this._anchorDragOffset.x -= offset.x;
+        this._anchorDragOffset.y -= offset.y;
+        newAttrs.width += this.padding() * 2;
+    }
+    if (this._movingAnchorName &&
+        newAttrs.height < 0 &&
+        this._movingAnchorName.indexOf('top') >= 0) {
+        const offset = t.point({
+            x: 0,
+            y: -this.padding() * 2,
+        });
+        newAttrs.x += offset.x;
+        newAttrs.y += offset.y;
+        this._movingAnchorName = this._movingAnchorName.replace('top', 'bottom');
+        this._anchorDragOffset.x -= offset.x;
+        this._anchorDragOffset.y -= offset.y;
+        newAttrs.height += this.padding() * 2;
+    }
+    else if (this._movingAnchorName &&
+        newAttrs.height < 0 &&
+        this._movingAnchorName.indexOf('bottom') >= 0) {
+        const offset = t.point({
+            x: 0,
+            y: this.padding() * 2,
+        });
+        this._movingAnchorName = this._movingAnchorName.replace('bottom', 'top');
+        this._anchorDragOffset.x -= offset.x;
+        this._anchorDragOffset.y -= offset.y;
+        newAttrs.height += this.padding() * 2;
+    }
+    if (this.boundBoxFunc()) {
+        const bounded = this.boundBoxFunc()(oldAttrs, newAttrs);
+        if (bounded) {
+            newAttrs = bounded;
+        }
+        else {
+            Util_1.Util.warn('boundBoxFunc returned falsy. You should return new bound rect from it!');
+        }
+    }
+    const baseSize = 10000000;
+    const oldTr = new Util_1.Transform();
+    oldTr.translate(oldAttrs.x, oldAttrs.y);
+    oldTr.rotate(oldAttrs.rotation);
+    oldTr.scale(oldAttrs.width / baseSize, oldAttrs.height / baseSize);
+    const newTr = new Util_1.Transform();
+    const newScaleX = newAttrs.width / baseSize;
+    const newScaleY = newAttrs.height / baseSize;
+    if (this.flipEnabled() === false) {
+        newTr.translate(newAttrs.x, newAttrs.y);
+        newTr.rotate(newAttrs.rotation);
+        newTr.translate(newAttrs.width < 0 ? newAttrs.width : 0, newAttrs.height < 0 ? newAttrs.height : 0);
+        newTr.scale(Math.abs(newScaleX), Math.abs(newScaleY));
+    }
+    else {
+        newTr.translate(newAttrs.x, newAttrs.y);
+        newTr.rotate(newAttrs.rotation);
+        newTr.scale(newScaleX, newScaleY);
+    }
+    const delta = newTr.multiply(oldTr.invert());
+    this._nodes.forEach((node) => {
+        var _a;
+        const parentTransform = node.getParent()!.getAbsoluteTransform();
+        const localTransform = node.getTransform().copy();
+        localTransform.translate(node.offsetX(), node.offsetY());
+        const newLocalTransform = new Util_1.Transform();
+        newLocalTransform
+            .multiply(parentTransform.copy().invert())
+            .multiply(delta)
+            .multiply(parentTransform)
+            .multiply(localTransform);
+        const attrs = newLocalTransform.decompose();
+        node.setAttrs(attrs);
+        (_a = node.getLayer()) === null || _a === void 0 ? void 0 : _a.batchDraw();
+    });
+    this.rotation(Util_1.Util._getRotation(newAttrs.rotation));
+    this._nodes.forEach((node) => {
+        this._fire('transform', { evt: evt, target: node });
+        node._fire('transform', { evt: evt, target: node });
+    });
+    this._resetTransformCache();
+    this.update();
+    this.getLayer()!.batchDraw();
+  }
+}

+ 11 - 0
src/draw/core/components/arrow/arrow.vue

@@ -0,0 +1,11 @@
+<template>
+	<v-arrow :config="dataToConfig(data)" v-if="!addMode">
+	</v-arrow>
+	<v-arrow :config="{...dataToConfig(data), opacity: 0.3}" v-else>
+	</v-arrow>
+</template>
+
+<script lang="ts" setup>
+import { ArrowData, dataToConfig } from "./";
+defineProps<{ data: ArrowData, addMode?: boolean }>()
+</script>

+ 48 - 0
src/draw/core/components/arrow/index.ts

@@ -0,0 +1,48 @@
+import { Pos } from "@/draw/utils/math.ts";
+import { flatPositions } from "@/draw/utils/shared.ts";
+import { InteractiveMessage } from "../../hook/use-interactive.ts";
+import { ArrowConfig } from "konva/lib/shapes/Arrow";
+
+export { default as Component } from "./arrow.vue";
+
+export const shapeName = "箭头";
+export const defaultStyle = {
+  stroke: "#000",
+  strokeWidth: 1,
+  rotation: 0,
+  fill: "#000",
+};
+
+export const addMode = 'area'
+
+export const style = {
+  default: defaultStyle,
+  focus: defaultStyle,
+  hover: defaultStyle,
+};
+
+export type ArrowData = Partial<typeof defaultStyle> & { points: Pos[] };
+
+export const dataToConfig = (data: ArrowData): ArrowConfig => ({
+  ...defaultStyle,
+  ...data,
+  points: flatPositions(data.points),
+});
+
+export const interactiveToData = (
+  info: InteractiveMessage,
+  preset: Partial<ArrowData> = {}
+): ArrowData | undefined => {
+  if (info.area) {
+    return interactiveFixData({ ...preset, points: [info.area[0]] }, info);
+  }
+};
+
+export const interactiveFixData = (
+  data: ArrowData,
+  info: InteractiveMessage,
+) => {
+  const area = info.area!;
+  data.points[1] = area[1];
+  return data;
+};

+ 11 - 0
src/draw/core/components/circle/circle.vue

@@ -0,0 +1,11 @@
+<template>
+	<v-circle :config="dataToConfig(data)" v-if="!addMode">
+	</v-circle>
+	<v-circle :config="{...dataToConfig(data), opacity: 0.3}" v-else>
+	</v-circle>
+</template>
+
+<script lang="ts" setup>
+import { CircleData, dataToConfig } from "./index";
+defineProps<{ data: CircleData, addMode?: boolean }>()
+</script>

+ 54 - 0
src/draw/core/components/circle/index.ts

@@ -0,0 +1,54 @@
+import { InteractiveMessage } from "../../hook/use-interactive.ts";
+import { CircleConfig } from "konva/lib/shapes/Circle";
+
+export { default as Component } from "./circle.vue";
+
+export const shapeName = "圆形";
+export const defaultStyle = {
+  stroke: "#000",
+  fill: "#fff",
+  strokeWidth: 1,
+};
+
+export const addMode = 'area'
+
+export const style = {
+  default: defaultStyle,
+  focus: defaultStyle,
+  hover: defaultStyle,
+};
+
+export type CircleData = Partial<typeof defaultStyle> & {
+  x: number;
+  y: number;
+  radius: number;
+};
+
+export const dataToConfig = (data: CircleData): CircleConfig => ({
+  ...defaultStyle,
+  ...data,
+});
+
+export const interactiveToData = (
+  info: InteractiveMessage,
+  preset: Partial<CircleData> = {}
+): CircleData | undefined => {
+  if (info.area) {
+    const item = {
+      ...preset,
+      ...info.area[0],
+    } as unknown as CircleData;
+    return interactiveFixData(item, info);
+  }
+};
+
+export const interactiveFixData = (
+  data: CircleData,
+  info: InteractiveMessage
+) => {
+  const area = info.area!;
+  const xr = Math.abs(area[1].x - area[0].x);
+  const yr = Math.abs(area[1].y - area[0].y);
+  data.radius = Math.max(xr, yr, 0.01);
+  return data;
+};

+ 69 - 0
src/draw/core/components/icon/icon.vue

@@ -0,0 +1,69 @@
+<template>
+	<v-group :config="groupConfig" v-if="groupConfig">
+		<v-rect :config="rectConfig"/>
+		<v-path v-for="config in pathConfigs" :config="config"/>
+	</v-group>
+</template>
+
+<script lang="ts" setup>
+import { IconData, dataToConfig } from "./index";
+import { computed, ref, watch } from "vue";
+import { getSvgContent, parseSvgContent, SVGParseResult } from "@/draw/utils/resource";
+
+const props = defineProps<{ data: IconData, addMode?: boolean }>()
+const svg = ref<SVGParseResult | null>(null)
+const data = computed(() => dataToConfig(props.data))
+
+watch(() => data.value.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,
+		offset: {x: svg.value!.x, y: svg.value!.y},
+		strokeScaleEnabled: data.value.strokeScaleEnabled,
+		fill: data.value.fill || path.fill,
+		stroke: data.value.stroke || path.stroke,
+		strokeWidth: data.value.strokeWidth || path.strokeWidth,
+	}))
+})
+
+
+const groupConfig = 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
+
+	const scale = {
+		x: w / svg.value.width,
+		y: h / svg.value.height,
+	}
+	return {
+		x: data.value.x,
+		y: data.value.y,
+		opacity: props.addMode ? 0.3 : 1,
+		offset: {
+			x: svg.value.width / 2,
+			y: svg.value.height / 2,
+		},
+		scale,
+	}
+})
+
+const rectConfig = computed(() => {
+	if (!svg.value) return null;
+	return {
+		fill: data.value.coverFill,
+		stroke: data.value.coverStroke,
+		strokeWidth: data.value.coverStrokeWidth,
+		width: svg.value.width ,
+		height: svg.value.height,
+	}
+})
+</script>

+ 57 - 0
src/draw/core/components/icon/index.ts

@@ -0,0 +1,57 @@
+import { InteractiveMessage } from "../../hook/use-interactive.ts";
+import { ImageConfig } from "konva/lib/shapes/Image";
+
+export { default as Component } from "./icon.vue";
+
+export const shapeName = "图例";
+export const defaultStyle = {
+  strokeScaleEnabled: true,
+  width: 80,
+  height: 80
+};
+
+export const addMode = 'dot'
+
+export const style = {
+  default: defaultStyle,
+  focus: defaultStyle,
+  hover: defaultStyle,
+};
+
+export type IconData = Partial<typeof defaultStyle> & {
+  fill?: string;
+  stroke?: string;
+  strokeWidth?: number;
+  coverFill?: string;
+  coverStroke?: string,
+  coverStrokeWidth?: number,
+  width: number;
+  height: number;
+  x: number;
+  y: number;
+  url: string
+};
+
+
+export const dataToConfig = (data: IconData): Omit<ImageConfig, 'image'> => ({
+  ...defaultStyle,
+  ...data
+})
+
+export const interactiveToData = (
+  info: InteractiveMessage,
+  preset: Partial<IconData> = {}
+): IconData | undefined => {
+  if (info.dot) {
+    return interactiveFixData({ ...preset, } as unknown as IconData, info);
+  }
+};
+
+export const interactiveFixData = (
+  data: IconData,
+  info: InteractiveMessage
+) => {
+  data.x = info.dot!.x
+  data.y = info.dot!.y
+  return data;
+};

+ 48 - 0
src/draw/core/components/image/image.vue

@@ -0,0 +1,48 @@
+<template>
+	<v-image :config="config" v-if="image"/>
+</template>
+
+<script lang="ts" setup>
+import { ImageData, dataToConfig } from "./index";
+import { computed, ref, watch } from "vue";
+import { getImage } from "@/draw/utils/resource";
+import { useResize } from "@/draw/core/hook/use-event.ts";
+
+const props = defineProps<{ data: ImageData, addMode?: boolean }>()
+const image = ref<HTMLImageElement | null>(null)
+
+console.log(props.data)
+watch(() => props.data.url, async url => {
+	image.value = null;
+	image.value = await getImage(url)
+
+}, {immediate: true})
+
+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
+	return {
+		...dataToConfig(props.data),
+		image: image.value,
+		opacity: props.addMode ? 0.3 : 1,
+		stroke: 'red',
+		width: w,
+		height: h,
+		offset: {
+			x: w / 2,
+			y: h / 2,
+		},
+	}
+})
+</script>

+ 51 - 0
src/draw/core/components/image/index.ts

@@ -0,0 +1,51 @@
+import { InteractiveMessage } from "../../hook/use-interactive.ts";
+import { ImageConfig } from "konva/lib/shapes/Image";
+
+export { default as Component } from "./image.vue";
+
+export const shapeName = "图片";
+export const defaultStyle = {};
+
+export const addMode = 'dot'
+
+export const style = {
+  default: defaultStyle,
+  focus: defaultStyle,
+  hover: defaultStyle,
+};
+
+export type ImageData = Partial<typeof defaultStyle> & {
+  fill?: string;
+  stroke?: string;
+  strokeWidth?: number;
+  cornerRadius: number
+  width: number;
+  height: number;
+  x: number;
+  y: number;
+  url: string
+};
+
+
+export const dataToConfig = (data: ImageData): Omit<ImageConfig, 'image'> => ({
+  ...defaultStyle,
+  ...data
+})
+
+export const interactiveToData = (
+  info: InteractiveMessage,
+  preset: Partial<ImageData> = {}
+): ImageData | undefined => {
+  if (info.dot) {
+    return interactiveFixData({ ...preset, } as unknown as ImageData, info);
+  }
+};
+
+export const interactiveFixData = (
+  data: ImageData,
+  info: InteractiveMessage
+) => {
+  data.x = info.dot!.x
+  data.y = info.dot!.y
+  return data;
+};

+ 50 - 0
src/draw/core/components/index.ts

@@ -0,0 +1,50 @@
+import * as arrow from './arrow'
+import * as rectangle from './rectangle'
+import * as circle from './circle'
+import * as triangle from './triangle'
+import * as polygon from './polygon'
+import * as line from './line'
+import * as text from './text'
+import * as icon from './icon'
+import * as image from './image'
+
+import { ArrowData } from './arrow'
+import { RectangleData } from './rectangle'
+import { CircleData } from './circle'
+import { TriangleData } from './triangle'
+import { PolygonData } from './polygon'
+import { LineData } from './line'
+import { TextData } from './text'
+import { IconData } from './icon'
+import { ImageData } from './image'
+
+export const components = {
+	arrow,
+	rectangle,
+	circle,
+	triangle,
+	polygon,
+	line,
+	text,
+	icon,
+	image
+}
+
+export type Components = typeof components
+export type ComponentValue<T extends ShapeType, K extends keyof Components[T]> = Components[T][K]
+
+export type DrawData = {
+	arrow?: ArrowData[],
+	rectangle?: RectangleData[],
+	circle?: CircleData[],
+	triangle?: TriangleData[],
+	polygon?: PolygonData[],
+	line?: LineData[],
+	text?: TextData[],
+	icon?: IconData[],
+	image?: ImageData[],
+}
+
+export type DrawItem<T extends ShapeType = ShapeType> = Required<DrawData>[T][number]
+
+export type ShapeType = keyof typeof components

+ 49 - 0
src/draw/core/components/line/index.ts

@@ -0,0 +1,49 @@
+import { Pos } from "@/draw/utils/math.ts";
+import { flatPositions } from "@/draw/utils/shared.ts";
+import { InteractiveAction, InteractiveMessage } from "../../hook/use-interactive.ts";
+import { LineConfig } from "konva/lib/shapes/Line";
+
+export { default as Component } from "./line.vue";
+
+export const shapeName = "线段";
+export const defaultStyle = {
+  stroke: "#000",
+  strokeWidth: 10,
+};
+
+export const addMode = 'dots'
+
+export const style = {
+  default: defaultStyle,
+  focus: defaultStyle,
+  hover: defaultStyle,
+};
+
+export type LineData = Partial<typeof defaultStyle> & { points: Pos[] };
+
+export const dataToConfig = (data: LineData): LineConfig => ({
+  ...defaultStyle,
+  ...data,
+  points: flatPositions(data.points),
+});
+
+export const interactiveToData = (
+  info: InteractiveMessage,
+  preset: Partial<LineData> = {}
+): LineData | undefined => {
+  if (info.dot) {
+    return interactiveFixData({ ...preset, points: [] }, info);
+  }
+};
+
+export const interactiveFixData = (
+  data: LineData,
+  info: InteractiveMessage
+) => {
+  if (info.action === InteractiveAction.delete) {
+    data.points.pop()
+  } else {
+    data.points[info.ndx!] = info.dot!
+  }
+  return data;
+};

+ 11 - 0
src/draw/core/components/line/line.vue

@@ -0,0 +1,11 @@
+<template>
+	<v-line :config="dataToConfig(data)" v-if="!addMode">
+	</v-line>
+	<v-line :config="{...dataToConfig(data), opacity: 0.3}" v-else>
+	</v-line>
+</template>
+
+<script lang="ts" setup>
+import { PolygonData, dataToConfig } from ".";
+defineProps<{ data: PolygonData, addMode?: boolean }>()
+</script>

+ 52 - 0
src/draw/core/components/polygon/index.ts

@@ -0,0 +1,52 @@
+import { Pos } from "@/draw/utils/math.ts";
+import { flatPositions } from "@/draw/utils/shared.ts";
+import { InteractiveAction, InteractiveMessage } from "../../hook/use-interactive.ts";
+import { LineConfig } from "konva/lib/shapes/Line";
+
+export { default as Component } from "./polygon.vue";
+
+export const shapeName = "多边形";
+export const defaultStyle = {
+  stroke: "#000",
+  strokeWidth: 10,
+  rotation: 0,
+  fill: "#fff",
+};
+
+export const addMode = 'dots'
+
+export const style = {
+  default: defaultStyle,
+  focus: defaultStyle,
+  hover: defaultStyle,
+};
+
+export type PolygonData = Partial<typeof defaultStyle> & { points: Pos[] };
+
+export const dataToConfig = (data: PolygonData): LineConfig => ({
+  ...defaultStyle,
+  ...data,
+  closed: true,
+  points: flatPositions(data.points),
+});
+
+export const interactiveToData = (
+  info: InteractiveMessage,
+  preset: Partial<PolygonData> = {}
+): PolygonData | undefined => {
+  if (info.dot) {
+    return interactiveFixData({ ...preset, points: [] }, info);
+  }
+};
+
+export const interactiveFixData = (
+  data: PolygonData,
+  info: InteractiveMessage
+) => {
+  if (info.action === InteractiveAction.delete) {
+    data.points.pop()
+  } else {
+    data.points[info.ndx!] = info.dot!
+  }
+  return data;
+};

+ 11 - 0
src/draw/core/components/polygon/polygon.vue

@@ -0,0 +1,11 @@
+<template>
+	<v-line :config="dataToConfig(data)" v-if="!addMode">
+	</v-line>
+	<v-line :config="{...dataToConfig(data), opacity: 0.3}" v-else>
+	</v-line>
+</template>
+
+<script lang="ts" setup>
+import { PolygonData, dataToConfig } from ".";
+defineProps<{ data: PolygonData, addMode?: boolean }>()
+</script>

+ 86 - 0
src/draw/core/components/rectangle/index.ts

@@ -0,0 +1,86 @@
+import { Pos } from "@/draw/utils/math.ts";
+import { InteractiveMessage } from "../../hook/use-interactive.ts";
+import { themeMouseColors } from "@/draw/constant/help-style.ts";
+import { flatPositions } from "@/draw/utils/shared";
+import { LineConfig } from "konva/lib/shapes/Line";
+
+export { default as Component } from "./rectangle.vue";
+export { default as TempComponent } from "./temp-rectangle.vue";
+
+
+export const shapeName = "矩形";
+export const defaultStyle = {
+  stroke: themeMouseColors.theme,
+  strokeWidth: 2,
+  fill: '#fff',
+};
+
+export const addMode = 'area'
+
+export const style = {
+  default: defaultStyle,
+  focus: {
+    fill: themeMouseColors.theme,
+    stroke: themeMouseColors.hover,
+  },
+  hover: {
+    stroke: themeMouseColors.theme,
+    // fill: themeMouseColors.hover,
+  },
+  press: {
+    stroke: themeMouseColors.press,
+    fill: themeMouseColors.press,
+  },
+  // select: {
+  //   fill: 'rgba(255,0,0, 1)',
+  // },
+  drag: {
+    strokeScaleEnabled: false,
+    fill: themeMouseColors.hover,
+    stroke: themeMouseColors.press,
+    dashEnabled: true,
+    dash: [30, 30]
+  }
+};
+
+export type RectangleData = Partial<typeof defaultStyle> & {
+  attitude: number[];
+  points: Pos[]
+};
+
+export const dataToConfig = (data: RectangleData): LineConfig => {
+  return {
+    ...data,
+    closed: true,
+    points: flatPositions(data.points),
+  };
+};
+
+export const interactiveToData = (
+  info: InteractiveMessage,
+  preset: Partial<RectangleData> = {}
+): RectangleData | undefined => {
+  if (info.area) {
+    const item = { ...preset } as unknown as RectangleData;
+    return interactiveFixData(item, info);
+  }
+};
+
+export const interactiveFixData = (
+  data: RectangleData,
+  info: InteractiveMessage
+) => {
+  if (info.area) {
+    const area = info.area!;
+    const width = area[1].x - area[0].x;
+    const height = area[1].y - area[0].y;
+
+    data.points = [
+      info.area[0],
+      {x: info.area[0].x + width, y: info.area[0].y},
+      {x: info.area[0].x + width, y: info.area[0].y + height},
+      {x: info.area[0].x, y: info.area[0].y + height},
+    ]
+  }
+  return data;
+};

+ 26 - 0
src/draw/core/components/rectangle/rectangle.vue

@@ -0,0 +1,26 @@
+<template>
+	<v-line :config="dataToConfig(data)" ref="shape"/>
+</template>
+
+<script lang="ts" setup>
+import { RectangleData, style, dataToConfig } from "./index";
+import { useMouseStyle } from "../../hook/use-mouse-status.ts";
+import { ref } from "vue";
+import { useLineTransformer } from "../../hook/use-transformer.ts";
+import { DC } from "@/draw/helper/deconstruction.js";
+import { useAniamtion } from "../../hook/use-animation.ts";
+import { Line } from "konva/lib/shapes/Line";
+
+const props = defineProps<{ data: RectangleData, addMode?: boolean }>()
+const emit = defineEmits<{ (e: 'update', value: RectangleData): void }>()
+const shape = ref<DC<Line>>()
+const transform = useLineTransformer(
+	shape, 
+	props.data,
+	(newData) => {
+		emit('update', { ...props.data, ...newData })
+	}
+)
+const { currentStyle } = useMouseStyle({ style, shape }, transform);
+useAniamtion(currentStyle, shape)
+</script>

+ 8 - 0
src/draw/core/components/rectangle/temp-rectangle.vue

@@ -0,0 +1,8 @@
+<template>
+	<v-line :config="{ ...style.default, ...dataToConfig(data), opacity: 0.3 }" />
+</template>
+
+<script lang="ts" setup>
+import { RectangleData, style, dataToConfig } from "./index";
+defineProps<{ data: RectangleData }>()
+</script>

+ 29 - 0
src/draw/core/components/share/point.vue

@@ -0,0 +1,29 @@
+<template>
+	<v-circle
+			:config="{  ...HelpPointStyle, ...position }"
+			ref="circle"
+	/>
+</template>
+
+<script lang="ts" setup>
+import { Pos } from "@/draw/utils/math.ts";
+import { HelpPointStyle } from "@/draw/constant/help-style.ts";
+import { ref, watch, watchEffect } from 'vue'
+import { DC } from "@/draw/helper/deconstruction";
+import { Circle } from "konva/lib/shapes/Circle";
+import { useMouseDrag } from "@/draw/core/hook/use-mouse-status.ts";
+
+const props = defineProps<{ position: Pos }>()
+const emit = defineEmits<{ (e: 'update:position', position: Pos): void, (e: 'dragend'): void }>()
+
+const circle = ref<DC<Circle>>()
+const { absPosition } = useMouseDrag(circle)
+
+watch(absPosition, (position) => {
+	if (position) {
+		emit('update:position', position)
+	} else {
+		emit('dragend')
+	}
+})
+</script>

+ 59 - 0
src/draw/core/components/text/index.ts

@@ -0,0 +1,59 @@
+import { InteractiveMessage } from "../../hook/use-interactive.ts";
+import { TextConfig } from "konva/lib/shapes/Text";
+
+export { default as Component } from "./text.vue";
+
+export const shapeName = "文字";
+export const defaultStyle = {
+  fill: "#000",
+  stroke: 'red',
+  strokeWidth: 3,
+  fontFamily: 'Calibri',
+  fontSize: 30,
+  maxWidth: 300
+};
+
+export const addMode = 'dot'
+
+export const style = {
+  default: defaultStyle,
+  focus: defaultStyle,
+  hover: defaultStyle,
+};
+
+export type TextData = Partial<typeof defaultStyle> & {
+  x: number;
+  y: number;
+  content: string
+};
+
+export const dataToConfig = (data: TextData): TextConfig => ({
+  ...defaultStyle,
+  ...data,
+  align: 'center',
+  verticalAlign: 'center',
+  text: data.content
+});
+
+export const interactiveToData = (
+  info: InteractiveMessage,
+  preset: Partial<TextData> = {}
+): TextData | undefined => {
+  if (info.dot) {
+    const item = {
+      ...defaultStyle,
+      ...preset,
+      content: preset.content || '文字',
+    } as unknown as TextData;
+    return interactiveFixData(item, info);
+  }
+};
+
+export const interactiveFixData = (
+  data: TextData,
+  info: InteractiveMessage
+) => {
+  data.x = info.dot!.x
+  data.y = info.dot!.y
+  return data;
+};

+ 50 - 0
src/draw/core/components/text/text.vue

@@ -0,0 +1,50 @@
+<template>
+	<v-text :config="config" ref="text">
+	</v-text>
+</template>
+
+<script lang="ts" setup>
+import { TextData, dataToConfig } from "./index";
+import { computed, nextTick, ref, watch } from "vue";
+import { DC } from "@/draw/helper/deconstruction";
+import { Text } from 'konva/lib/shapes/Text'
+import { Pos } from "@/draw/utils/math.ts";
+
+const props = defineProps<{ data: TextData, addMode?: boolean }>()
+const text = ref<DC<Text>>()
+
+const width = ref<number>()
+const offset = ref<Pos>()
+const refreshOffset = async () => {
+	const $text = text.value!.getStage()
+	const oldWidth = width.value
+	const oldHeight = $text.height()
+	width.value = void 0;
+	if (!props.data.maxWidth) {
+		return;
+	}
+	await nextTick()
+	width.value = Math.min($text.width(), props.data.maxWidth!)
+	if (oldWidth !== width.value || oldHeight !==  $text.height()) {
+		await nextTick()
+		offset.value = {
+			x: $text.width() / 2,
+			y: $text.height() / 2,
+		}
+	}
+}
+
+const config = computed(() => ({
+	...dataToConfig(props.data),
+	offset: offset.value,
+	width: width.value,
+	opacity: props.addMode ? 0.3 : 1
+}))
+
+watch(
+		() => [text.value, props.data.content, props.data.maxWidth, props.data.fontSize],
+		() => {
+			text.value && refreshOffset()
+		}
+)
+</script>

+ 66 - 0
src/draw/core/components/triangle/index.ts

@@ -0,0 +1,66 @@
+import { InteractiveMessage } from "../../hook/use-interactive";
+import { LineConfig } from "konva/lib/shapes/Line";
+import { Pos } from "@/draw/utils/math";
+import { flatPositions } from "@/draw/utils/shared";
+import { themeMouseColors } from "@/draw/constant/help-style";
+
+export { default as Component } from "./triangle.vue";
+export { default as TempComponent } from "./temp-triangle.vue";
+
+export const shapeName = "三角形";
+export const defaultStyle = {
+  stroke: themeMouseColors.theme,
+  strokeWidth: 5,
+  fill: '#fff',
+};
+
+export const addMode = 'area'
+
+export const style = {
+  default: defaultStyle,
+  focus: {
+    fill: themeMouseColors.theme,
+    stroke: themeMouseColors.hover,
+  },
+  hover: {
+    stroke: themeMouseColors.theme,
+  }
+}
+
+export type TriangleData = Partial<typeof defaultStyle> & { points: Pos[], recordRotation: number };
+
+export const dataToConfig = (data: TriangleData): LineConfig => {
+  return {
+    ...data,
+    closed: true,
+    points: flatPositions(data.points),
+  };
+};
+
+export const interactiveToData = (
+  info: InteractiveMessage,
+  preset: Partial<TriangleData> = {}
+): TriangleData | undefined => {
+  if (info.area) {
+    const item = {
+      ...preset,
+      points: [],
+    } as unknown as TriangleData;
+    return interactiveFixData(item, info);
+  }
+};
+
+export const interactiveFixData = (
+  data: TriangleData,
+  info: InteractiveMessage
+) => {
+  const area = info.area!;
+  
+  data.points[0] = {
+    x: area[0].x - (area[1].x - area[0].x),
+    y: area[1].y
+  }
+  data.points[1] = area[0];
+  data.points[2] = area[1];
+  return data;
+};

+ 8 - 0
src/draw/core/components/triangle/temp-triangle.vue

@@ -0,0 +1,8 @@
+<template>
+	<v-line :config="{...dataToConfig(data), opacity: 0.3}" />
+</template>
+
+<script lang="ts" setup>
+import { TriangleData, dataToConfig } from "./index";
+defineProps<{ data: TriangleData }>()
+</script>

+ 52 - 0
src/draw/core/components/triangle/triangle.vue

@@ -0,0 +1,52 @@
+<template>
+	<v-line :config="dataToConfig(atData)" ref="shape">
+	</v-line>
+</template>
+
+<script lang="ts" setup>
+import { TriangleData, style, dataToConfig } from "./index";
+import { useMouseStyle } from "../../hook/use-mouse-status.ts";
+import { ref, watch } from "vue";
+import { useAutomaticData } from "@/draw/core/hook/use-automatic-data.ts";
+import { useShapeTransformer } from "../../hook/use-transformer.ts";
+import { DC } from "@/draw/helper/deconstruction.js";
+import { Rect } from "konva/lib/shapes/Rect";
+import { useAniamtion } from "../../hook/use-animation.ts";
+
+const props = defineProps<{ data: TriangleData, }>()
+const emit = defineEmits<{ (e: 'update', value: TriangleData): void }>()
+const atData = useAutomaticData(() => props.data)
+const shape = ref<DC<Rect>>()
+const transformer = useShapeTransformer(shape)
+const { currentStyle } = useMouseStyle({ style, shape }, transformer);
+useAniamtion(currentStyle, shape)
+
+watch(() => transformer.offset, (offset) => {
+	if (offset) {
+		for (let i = 0; i < props.data.points.length; i++) {
+			atData.value.points[i].x = props.data.points[i].x + offset.x
+			atData.value.points[i].y = props.data.points[i].y + offset.y
+		}
+	} else {
+		emit('update', atData.value)
+	}
+})
+
+
+watch(() => transformer.transform, (transform, oldTransform) => {
+	if (!transform && oldTransform) {
+		const $shape = shape.value!.getNode()
+		for (let i = 0; i < atData.value.points.length; i++) {
+			const position = oldTransform.point(atData.value.points[i])
+			atData.value.points[i].x = position.x
+			atData.value.points[i].y = position.y
+		}
+		atData.value.recordRotation = oldTransform.decompose().rotation
+		$shape.rotation(0)
+		$shape.scale({x: 1, y: 1})
+		$shape.position({x: 0, y: 0})
+		emit('update', atData.value)
+	}
+})
+
+</script>

+ 122 - 0
src/draw/core/history.ts

@@ -0,0 +1,122 @@
+import { History } from "stateshot";
+import { inRevise } from "../utils/shared";
+
+export type HistoryState = {
+	hasUndo: boolean;
+	hasRedo: boolean;
+};
+
+export type StateCallback = (state: HistoryState) => void;
+
+export class SingleHistory<T = any> {
+	stateChange: StateCallback;
+	history: History<{ data: T }>;
+	state = {
+		hasUndo: false,
+		hasRedo: false,
+	};
+
+	constructor(stateChange: StateCallback) {
+		this.stateChange = stateChange;
+		this.history = new History();
+	}
+
+	private prevState: HistoryState | null = null;
+	private syncState() {
+		if (!this.history) return;
+		this.state.hasRedo = this.history.hasRedo;
+		this.state.hasUndo = this.history.hasUndo;
+
+		if (inRevise(this.state, this.prevState)) {
+			this.stateChange({ ...this.state });
+			this.prevState = { ...this.state };
+		}
+	}
+
+	get data() {
+		return this.history.get()?.data;
+	}
+
+	undo(): T {
+		if (this.history.hasUndo) {
+			this.history.undo();
+			this.syncState();
+		}
+		return this.data;
+	}
+
+	redo(): T {
+		if (this.history.hasRedo) {
+			this.history.redo();
+			this.syncState();
+		}
+		return this.data;
+	}
+
+	push(data: T) {
+		this.history.pushSync({ data });
+		this.syncState();
+	}
+
+	clear() {
+		this.history.reset();
+	}
+}
+
+
+
+export class MergeHistory<T> {
+	stateChange: StateCallback;
+	historyStack: SingleHistory<T>[] = [];
+
+	constructor(stateChange: StateCallback) {
+		this.stateChange = stateChange;
+	}
+
+	get current() {
+		return this.historyStack[this.historyStack.length - 1];
+	}
+
+	private prevState: HistoryState | null = null;
+	branch() {
+		const single = new SingleHistory((state) => {
+			if (inRevise(this.prevState, state)) {
+				this.stateChange({ ...state });
+				this.prevState = { ...state };
+			}
+		});
+		this.historyStack.push(single);
+	}
+
+	merge() {
+		const lastStack = this.historyStack.pop()!;
+		if (lastStack.state.hasUndo) {
+			this.current.push(lastStack.data);
+		}
+		lastStack.clear();
+	}
+
+	undo() {
+		return this.current.undo();
+	}
+
+	redo() {
+		return this.current.redo();
+	}
+
+	push(data: T) {
+		return this.current.push(data);
+	}
+
+	clear() {
+		return this.current.clear();
+	}
+
+	get data() {
+		return this.current.data;
+	}
+
+	destory() {
+		this.historyStack.length = 0;
+	}
+}

+ 109 - 0
src/draw/core/hook/use-animation.ts

@@ -0,0 +1,109 @@
+import { onUnmounted, ref, Ref, watch, watchEffect } from "vue"
+import { Tween, Easing } from '@tweenjs/tween.js'
+import { inRevise } from "@/draw/utils/shared"
+import { Color, RGB } from "three"
+import { DC } from "@/draw/helper/deconstruction"
+import { Shape } from "konva/lib/Shape"
+
+const pickColors = <T extends object>(origin: T): Record<string, RGB>  => {
+  const originColors = {} as any
+  for (const [key, val] of Object.entries(origin)) {
+    if (typeof val === 'string') {
+      originColors[key] = new Color(val).getRGB({} as any)
+    }
+  }
+  return originColors
+}
+const resumeColors = <T extends object>(origin: T, colors: Record<string, RGB>) => {
+  for (const [key, val] of Object.entries(colors)) {
+    (origin as any)[key] = `#${new Color(val.r, val.g, val.b).getHexString()}`
+  }
+}
+
+const easing = Easing.Quadratic.InOut
+const animation = <T extends object>(origin: T, target: T) => {
+  let isStop = false
+  const stop = () => {
+    numTw.stop()
+    colorTw.stop()
+    isStop = true
+  }
+  const oColors = pickColors(origin);
+  const tColors = pickColors(target)
+  const colorKeys = new Set([...Object.keys(oColors), ...Object.keys(tColors)])
+  const tOrigin = {...origin}
+  const tTarget = {...target}
+
+  for (const key in tOrigin) {
+    if (colorKeys.has(key)) {
+      delete tOrigin[key]
+    }
+  }
+  for (const key in tTarget) {
+    if (colorKeys.has(key)) {
+      delete tTarget[key]
+    }
+  }
+
+  let count = 0
+  const tw = (origin: any, target: any) => {
+    ++count
+    return new Tween(origin)
+      .to(target, 150)
+      .easing(easing)
+      .start()
+      .onComplete(() => {
+        if (--count === 0) stop()
+      })
+  }
+
+  const numTw = tw(tOrigin, tTarget)
+    .onUpdate(() => {
+      Object.assign(origin, tOrigin)
+    })
+  
+  const colorTw = tw(oColors, tColors)
+    .onUpdate(() => {
+      resumeColors(origin, oColors)
+    })
+
+  const start = () => {
+    requestAnimationFrame(() => {
+      numTw.update()
+      colorTw.update()
+      isStop || start()
+    })
+  }
+  start()
+  return stop
+}
+
+
+export const useAniamtion = <T extends object>(data: Ref<T>, shape?: Ref<DC<Shape>| undefined>) => {
+  const atData = ref<T>({...data.value})
+  let animationStop: () => void
+
+  const { pause, resume } = watch(data, (newData) => {
+    if (!inRevise(newData, atData.value)) return;
+    animationStop! && animationStop()
+    animationStop = animation(atData.value, newData)
+  })
+
+  const stopShapeSet = watchEffect(() => {
+    if (!shape?.value) return;
+    const $shape = shape.value.getNode() as any
+    for (const key in atData.value) {
+      $shape[key](atData.value[key])
+    }
+  })
+
+  onUnmounted(() => {
+    animationStop && animationStop()
+    stopShapeSet()
+  })
+  return {
+    data: atData,
+    pause,
+    resume
+  }
+}

+ 14 - 0
src/draw/core/hook/use-automatic-data.ts

@@ -0,0 +1,14 @@
+import { Ref, ref, watch } from 'vue'
+
+const parseCopy = <T>(data: T): T => JSON.parse(JSON.stringify(data))
+
+export const useAutomaticData = <T>(
+	getter: () => T,
+	copy = parseCopy
+) => {
+	const data = ref() as Ref<T>
+	watch(getter, (newData) => {
+		data.value = copy(newData)
+	}, { immediate: true })
+	return data;
+}

+ 11 - 0
src/draw/core/hook/use-coversion-position.ts

@@ -0,0 +1,11 @@
+import { Pos } from "../../utils/math.ts";
+import { useViewerInvertTransform } from "./use-viewer.ts";
+
+export const useConversionPosition = (enable: boolean) => {
+	const invertTransform = enable && useViewerInvertTransform();
+	return (position: Pos) => {
+		return invertTransform
+			? invertTransform.value.point(position)
+			: position;
+	}
+};

+ 52 - 0
src/draw/core/hook/use-event.ts

@@ -0,0 +1,52 @@
+import { listener } from "../../utils/event.ts";
+import { useStage } from "./use-global-vars.ts";
+import { nextTick, ref, watchEffect } from "vue";
+
+export const useListener = <
+  T extends HTMLElement,
+  K extends keyof HTMLElementEventMap
+>(
+  eventName: K,
+  callback: (
+    this: T,
+    ev: HTMLElementEventMap[K],
+    stageDOM: HTMLDivElement
+  ) => any,
+  target?: HTMLElement | Window
+) => {
+  const stage = useStage();
+  return watchEffect((onCleanup) => {
+    if (stage.value) {
+      const $stage = stage.value!.getStage();
+      const dom = $stage.container() as any;
+      onCleanup(
+        listener(target || dom, eventName, function (ev) {
+          callback.call(this, ev, dom);
+        })
+      );
+    }
+  });
+};
+
+export const useResize = () => {
+  const size = ref<{ width: number; height: number }>();
+  const stage = useStage();
+  const setSize = () => {
+    const dom = stage.value!.getNode().container().parentElement!;
+    size.value = {
+			width: dom.offsetWidth,
+			height: dom.offsetHeight
+		}
+  };
+
+  const stopWatch = watchEffect(() => {
+    if (stage.value) {
+      setSize();
+      nextTick(() => stopWatch());
+    }
+  });
+
+  useListener("resize", setSize, window);
+
+  return size;
+};

+ 30 - 0
src/draw/core/hook/use-expose.ts

@@ -0,0 +1,30 @@
+import { useMode, useStage } from "./use-global-vars.ts";
+import { Stage } from "konva/lib/Stage";
+import { reactive } from "vue";
+import { useInteractiveProps, useInteractiveShapeAPI } from "./use-interactive.ts";
+
+type PickParams<K extends keyof Stage, O extends string> = Stage[K]  extends (...args: any) => any ?  Omit<Required<Parameters<Stage[K]>>[0], O> : never
+
+export type DrawExpose = ReturnType<typeof useExpose>
+
+export const useExpose = () => {
+	const mode = useMode()
+	const interactiveProps = useInteractiveProps()
+	const stage = useStage()
+
+
+	const exposeBlob = (config?: PickParams<'toBlob', 'callback'>) => {
+		const $stage = stage.value!.getStage()
+		return new Promise<Blob>(resolve => {
+			$stage.toBlob({ ...config, resolve } as any)
+		})
+	}
+
+	return reactive({
+		...useInteractiveShapeAPI(),
+		exposeBlob,
+		mode,
+		presetAdd: interactiveProps,
+	})
+}
+

+ 104 - 0
src/draw/core/hook/use-global-vars.ts

@@ -0,0 +1,104 @@
+import { DC } from "../../helper/deconstruction";
+import { Stage } from "konva/lib/Stage";
+import {
+	getCurrentInstance,
+	nextTick,
+	onUnmounted,
+	reactive,
+	shallowRef,
+	watch,
+	WatchCallback,
+	WatchOptions,
+	WatchSource,
+} from "vue";
+import { Mode } from "../../constant/mode.ts";
+
+export const installGlobalVar = <T>(
+	create: () => { var: T, onDestory: () => void } | T,
+	key = Symbol('globalVar'),
+	noRefDel = true,
+) => {
+	let initialed = false
+	let refCount = 0;
+	let onDestory: (() => void) | null = null
+
+	const useGlobalVar = (): T => {
+		const instance = getCurrentInstance() as any;
+		const ctx = instance.appContext
+		if (!initialed) {
+			let val = create() as any
+			if (typeof val === 'object' && 'var' in val && 'onDestory' in val) {
+				onDestory = val.onDestory
+				val = val.var;
+			}
+			ctx[key] = val
+			initialed = true;
+		}
+		return ctx[key];
+	}
+
+	return noRefDel
+		? () => {
+				const instance = getCurrentInstance() as any;
+				const ctx = instance.appContext
+				++refCount;
+				onUnmounted(() => {
+					if (--refCount === 0 && noRefDel) {
+						initialed = false;
+						delete ctx[key]
+						console.log('销毁', key)
+						onDestory && onDestory()
+						onDestory = null
+					}
+				})
+				return useGlobalVar();
+			}
+		: useGlobalVar
+};
+
+export const stackVar = <T>(init?: T) => {
+	const stack = reactive([init]) as T[]
+	const result = {
+		get value() {
+			return stack[stack.length - 1]
+		},
+		set value(val) {
+			stack[stack.length - 1] = val
+		},
+		push(data: T) {
+			stack.push(data)
+		},
+		pop() {
+			if (stack.length - 1 > 0) {
+				stack.pop()
+			} else {
+				console.error('已到达栈顶')
+			}
+		},
+		cycle<R>(data: T, run: () => R): R {
+			result.push(data)
+			const r = run()
+			result.pop()
+			return r;
+		}
+	}
+	return result
+}
+
+export const globalWatch = <T>(
+	source: WatchSource<T>, 
+	cb: WatchCallback<T, T>, 
+	options?: WatchOptions
+): () => void => {
+	let stop: () => void
+	nextTick(() => {
+		stop = watch(source, cb as any, options as any)
+	})
+	return () => {
+		stop && stop()
+	}
+}
+
+export const useStage = installGlobalVar(() => shallowRef<DC<Stage> | undefined>(), Symbol("stage"));
+export const useMode = installGlobalVar(() => stackVar(Mode.viewer), Symbol("mode"));
+

+ 268 - 0
src/draw/core/hook/use-interactive.ts

@@ -0,0 +1,268 @@
+import { installGlobalVar, useMode, useStage, } from "./use-global-vars.ts";
+import { ComponentValue, DrawItem, ShapeType } from "../components";
+import { nextTick, reactive, Ref, ref, watch, watchEffect } from "vue";
+import { Pos } from "../../utils/math.ts";
+import { clickListener, dragListener, getOffset, listener } from "../../utils/event.ts";
+import { Mode } from "../../constant/mode.ts";
+import { inRevise, mergeFuns } from "../../utils/shared.ts";
+import { useConversionPosition } from "./use-coversion-position.ts";
+
+
+export type InteractivePreset<T extends ShapeType = ShapeType> = {
+	type: T;
+	preset?: Partial<DrawItem<T>>,
+	operate?: {
+		immediate?: boolean,
+		single?: boolean,
+		data?: any
+	}
+};
+export const useInteractiveProps = installGlobalVar(() => ref<InteractivePreset | undefined>(), Symbol("interactiveProps"));
+
+type Area = [Pos, Pos];
+export enum InteractiveAction { delete }
+export type InteractiveMessage = { area?: Area; dot?: Pos, ndx?: number, action?: InteractiveAction };
+export type InteractiveMessageData<T extends ShapeType> = ComponentValue<T, 'addMode'> extends 'area' ? Area : Pos
+export type InteractiveAreas = ReturnType<typeof useInteractiveAreas>;
+export type InteractiveDots = ReturnType<typeof useInteractiveDots>;
+export type Interactive = InteractiveAreas | InteractiveDots
+
+export const useInteractiveShapeAPI = () => {
+	const mode = useMode();
+	const interactiveProps = useInteractiveProps();
+	const conversion = useConversionPosition(true)
+	return {
+		addShape: <T extends ShapeType>(
+			shapeType: T, 
+			preset: Partial<DrawItem<T>> = {}, 
+			data: InteractiveMessageData<T>, 
+			pixel = false
+		) => {
+			mode.value = Mode.add
+			if (pixel) {
+				data = (Array.isArray(data) ? data.map(conversion) : conversion(data)) as InteractiveMessageData<T>
+			}
+			interactiveProps.value = {
+				type: shapeType, preset, 
+				operate: { single: true, immediate: true, data }
+			}
+		},
+		enterMouseAddShape: <T extends ShapeType>(shapeType: T, preset: Partial<DrawItem<T>> = {}, single = false) => {
+			mode.value = Mode.add
+			interactiveProps.value = {type: shapeType, preset, operate: {single}}
+		},
+		quitMouseAddShape: () => {
+			mode.value = Mode.viewer
+			interactiveProps.value = void 0
+		}
+	}
+}
+
+
+export const useIsRunning = (shapeType?: ShapeType) => {
+	const stage = useStage();
+	const mode = useMode();
+	const interactiveProps = useInteractiveProps();
+	const isRunning = ref<boolean>(false);
+	const updateIsRunning = () => {
+		isRunning.value = !!(stage.value &&
+			mode.value === Mode.add &&
+			(!shapeType || shapeType === interactiveProps.value?.type))
+	}
+	watchEffect(updateIsRunning)
+	watch(
+		() => interactiveProps.value?.preset,
+		(nPreset, oPreset) => {
+			if (isRunning.value && inRevise(nPreset, oPreset)) {
+				isRunning.value = false
+				nextTick(updateIsRunning)
+			}
+		}, {flush: 'post'})
+
+	return isRunning
+};
+
+
+const useInteractiveExpose = <T extends object>(
+	messages: Ref<T[]>,
+	init: (dom: HTMLDivElement) => () => void,
+	singleDone: Ref<boolean>,
+	shapeType?: ShapeType,
+	autoConsumed?: boolean,
+) => {
+	const consumedMessages = reactive(new WeakSet<T>()) as WeakSet<T>;
+	const stage = useStage();
+	const interactiveProps = useInteractiveProps();
+	const isRunning = useIsRunning(shapeType);
+	const {quitMouseAddShape} = useInteractiveShapeAPI()
+
+	watch(isRunning, (can, _, onCleanup) => {
+		if (can) {
+			const props = interactiveProps.value!
+			const cleanups = [] as Array<() => void>
+			if (props.operate?.single) {
+				// 如果指定单次则消息中有信息,并且确定完成则马上退出
+				cleanups.push(
+					watchEffect(() => {
+						if (messages.value.length > 0 && singleDone.value) {
+							quitMouseAddShape()
+						}
+					}, {flush: 'post'})
+				)
+			}
+
+			// 单纯添加
+			if (props.operate?.immediate) {
+				messages.value.push(props.operate.data as T)
+				singleDone.value = true
+			} else {
+				const $stage = stage.value!.getStage();
+				const dom = $stage.container();
+				cleanups.push(init(dom))
+			}
+			onCleanup(mergeFuns(cleanups));
+		} else {
+			messages.value = [];
+		}
+	});
+
+	return {
+		isRunning,
+		get preset() {
+			return interactiveProps.value?.preset;
+		},
+		get messages() {
+			const items = messages.value;
+			const result = items.filter((item) => !consumedMessages.has(item));
+			autoConsumed && result.forEach((item) => consumedMessages.add(item));
+			return result as T[];
+		},
+		getNdx(item: T) {
+			return messages.value.indexOf(item)
+		},
+		get consumedMessage() {
+			const items = messages.value;
+			return items.filter((item) => consumedMessages.has(item)) as T[];
+		},
+		consume(items: T[]) {
+			items.forEach((item) => consumedMessages.add(item));
+		},
+		singleDone
+	};
+}
+
+
+type UseInteractiveProps = {
+	shapeType?: ShapeType;
+	enableTransform?: boolean;
+	autoConsumed?: boolean;
+};
+
+
+export const useInteractiveAreas = ({
+																			shapeType,
+																			enableTransform,
+																			autoConsumed,
+																		}: UseInteractiveProps = {}) => {
+	if (enableTransform === void 0) enableTransform = true;
+
+	const singleDone = ref(true);
+	const messages = ref<Area[]>([])
+	const conversionPosition = useConversionPosition(enableTransform);
+
+	const init = (dom: HTMLDivElement) => {
+		let pushed = false;
+		let pushNdx = -1;
+		let downed = false;
+		let tempArea: Area;
+		let dragging = false
+		return dragListener(dom, {
+			down(position, ev) {
+				if (ev.button === 0) {
+					tempArea = [conversionPosition(position)] as unknown as Area;
+					downed = true;
+					singleDone.value = false;
+					dragging = false
+				}
+			},
+			move({end}) {
+				if (!downed) return;
+				if (pushed) {
+					messages.value[pushNdx]![1] = conversionPosition(end);
+				} else {
+					tempArea[1] = conversionPosition(end);
+					pushed = true;
+					pushNdx = messages.value.length;
+					messages.value[pushNdx] = tempArea;
+				}
+				dragging = true
+			},
+			up(position) {
+				if (!downed || !dragging) return;
+				messages.value[pushNdx]![1] = conversionPosition(position);
+				pushNdx = -1;
+				pushed = false;
+				downed = false;
+				dragging = false;
+				singleDone.value = true;
+			},
+		});
+	};
+
+	return useInteractiveExpose(
+		messages,
+		init,
+		singleDone,
+		shapeType,
+		autoConsumed
+	)
+};
+
+export const useInteractiveDots = ({
+																		 shapeType,
+																		 enableTransform,
+																		 autoConsumed,
+																	 }: UseInteractiveProps = {}) => {
+	if (enableTransform === void 0) enableTransform = true;
+	if (autoConsumed === void 0) autoConsumed = false;
+
+	const singleDone = ref(true);
+	const conversionPosition = useConversionPosition(enableTransform);
+	const messages = ref<Pos[]>([])
+
+	const init = (dom: HTMLDivElement) => {
+		let moveIng = false;
+		let pushed = false;
+		const empty = {x: -9999, y: -9999}
+		const pointer = ref(empty);
+
+		return mergeFuns(
+			clickListener(dom, () => {
+				if (!moveIng) return;
+				pointer.value = {...empty}
+				singleDone.value = true
+				moveIng = false
+				pushed = false
+			}),
+			listener(dom, "pointermove", (ev) => {
+				if (!pushed) {
+					messages.value.push(pointer.value);
+					singleDone.value = false;
+					pushed = true
+				}
+
+				moveIng = true
+				const current = conversionPosition(getOffset(ev))
+				pointer.value.x = current.x;
+				pointer.value.y = current.y;
+			})
+		);
+	};
+	return useInteractiveExpose(
+		messages,
+		init,
+		singleDone,
+		shapeType,
+		autoConsumed
+	)
+};

+ 197 - 0
src/draw/core/hook/use-mouse-status.ts

@@ -0,0 +1,197 @@
+import { computed, nextTick, reactive, ref, Ref, watch,  } from "vue";
+import { DC, EntityShape } from "../../helper/deconstruction";
+import { Shape } from "konva/lib/Shape";
+import { globalWatch, installGlobalVar, useStage } from "./use-global-vars.ts";
+import { Stage } from "konva/lib/Stage";
+import { listener } from "../../utils/event.ts";
+import { mergeFuns } from "../../utils/shared.ts";
+import { ComponentValue, ShapeType } from "../components";
+import { shapeTreeContain } from "../../utils/shape.ts";
+import { transformRectPadding, useShapeTransformer, useTransformer } from "./use-transformer.ts";
+import { Util } from "konva/lib/Util";
+
+export const useMouseShapesStatus = installGlobalVar(
+	() => {
+		const stage = useStage()
+		const listeners = ref([]) as Ref<EntityShape[]>
+		const hovers = ref([]) as Ref<EntityShape[]>
+		const press = ref([]) as Ref<EntityShape[]>
+		const selects = ref([]) as Ref<EntityShape[]>
+		const actives = ref([]) as Ref<EntityShape[]>
+		const transformer = useTransformer()
+
+	
+		const init = (stage: Stage) => {
+			let downTime: number
+			let downTarget: EntityShape
+
+			stage.on('pointerenter.mouse-status', async (ev) => {
+				const target = shapeTreeContain(listeners.value, ev.target)
+				if (!target || hovers.value.includes(target)) return;
+
+				let timeout: number
+				const targetLeave = () => {
+					clearTimeout(timeout)
+					timeout = setTimeout(() => {
+						target.off('pointerleave.mouse-status')
+						stage.off('pointermove.mouse-status')
+						const ndx = hovers.value.indexOf(target)
+						if (~ndx) {
+							hovers.value.splice(ndx, 1)
+						}	
+					})
+				}
+				const targetEnter = () => {
+					clearTimeout(timeout)
+					if (!hovers.value.includes(target)) {
+						hovers.value.push(target)
+					}
+				}
+				targetEnter()
+
+				target.on('pointerleave.mouse-status', ev => {
+					target === ev.target && targetLeave()
+				})
+				// 有可能外面套了transformer要监听是否真正离开
+				await nextTick()
+				console.log(transformer.queueShapes, target)
+				if (!transformer.queueShapes.includes(target)) return;
+				const moveHandler = () => {
+					const tfRect = transformer.getClientRect()
+					tfRect.x -= transformRectPadding
+					tfRect.y -= transformRectPadding
+					tfRect.width += transformRectPadding
+					tfRect.height += transformRectPadding
+					const pointRect = { ...stage.pointerPos!, width: 1, height: 1 }
+					
+					if (Util.haveIntersection(tfRect, pointRect)) {
+						targetEnter()
+					} else {
+						targetLeave()
+					}
+				}
+				stage.on('pointermove.mouse-status', moveHandler)
+			})
+			stage.on('pointerdown.mouse-status', (ev) => {
+				downTime = Date.now()
+				downTarget = ev.target as any
+				const target = shapeTreeContain(listeners.value, ev.target)
+				if (target && !press.value.includes(target)) {
+					press.value.push(target)
+				}
+			})
+	
+			return mergeFuns(
+				listener(stage.container(), 'pointerup', () => {
+					if (Date.now() - downTime < 300) {
+						const ndx = selects.value.indexOf(downTarget)
+						if (~ndx) {
+							selects.value.splice(ndx, 1)
+						} else {
+							selects.value.push(downTarget)
+						}
+						actives.value = [downTarget]
+					}
+					press.value = []
+				}),
+				() => {
+					listeners.value.forEach((shape) => {
+						shape.off('pointerleave.mouse-status')
+					})
+					stage.off('pointerenter.mouse-status pointerdown.mouse-status pointereup.mouse-status pointermove.mouse-status')
+					hovers.value = []
+					actives.value = []
+					press.value = []
+					selects.value = []
+					listeners.value = []
+				}
+			)
+		}
+
+		return {
+			var: reactive({hovers, actives, selects, press, listeners}),
+			onDestory: globalWatch(
+				() => stage.value?.getStage(), 
+				(stage, _, onCleanup) => {
+					if (stage) {
+						onCleanup(init(stage))
+					}
+				},
+				{ immediate: true }
+			)
+		}
+	},
+	Symbol('mouseStatus')
+)
+
+
+export const useMouseShapeStatus = (shape: Ref<DC<Shape> | undefined>) => {
+	const status = useMouseShapesStatus()
+	watch(
+		() => shape.value?.getStage(), 
+		(shape, _, onCleanup) => {
+		if (shape) {
+			if (status.listeners.includes(shape)) return;
+			status.listeners.push(shape)
+			onCleanup(() => {
+				for (const key in status) {
+					const ndx = status[key as keyof typeof status].indexOf(shape)
+					status.listeners.splice(ndx, 1)
+				}
+			})
+		}
+	})
+
+
+	return computed(() => {
+		const $shape = shape.value?.getStage() as Shape
+		return {
+			hover: status.hovers.includes($shape),
+			active: status.actives.includes($shape),
+			press:  status.press.includes($shape),
+			select: status.selects.includes($shape),
+		}
+	})
+}
+
+
+type MouseStyleProps<T extends ShapeType> = {
+	shape?: Ref<DC<Shape> | undefined>
+	style: ComponentValue<T, 'style'>
+}
+type ValueOf<T> = T[keyof T]
+export const useMouseStyle = <T extends ShapeType>(
+	props: MouseStyleProps<T>, 
+	tf?: ReturnType<typeof useShapeTransformer>
+) => {
+	const shape = props.shape || ref()
+	const status = useMouseShapeStatus(shape)
+
+	const style = computed(() => {
+		const styleMap = new Map([[props.style.default, true]])
+		if ('hover' in props.style) {
+			styleMap.set(props.style.hover, status.value.hover)
+		}
+		if ('press' in props.style) {
+			styleMap.set(props.style.press, status.value.press)
+		}
+		if ('focus' in props.style) {
+			styleMap.set(props.style.focus, status.value.active)
+		}
+		if ( 'drag' in props.style && tf) {
+			styleMap.set(props.style.drag, !!tf.value)
+		}
+		// if ('select' in props.style) {
+		// 	styleMap.set(props.style.select, status.value.select)
+		// }
+
+		const finalStyle = {} as ValueOf<ComponentValue<T, 'style'>>
+		for (const [style, use] of styleMap.entries()) {
+			use && Object.assign(finalStyle as any, style)
+		}
+		return finalStyle
+	})
+
+	return { currentStyle: style, status, shape }
+}
+

+ 227 - 0
src/draw/core/hook/use-transformer.ts

@@ -0,0 +1,227 @@
+import { useMouseShapeStatus } from "./use-mouse-status.ts";
+import { Ref, ref, watch } from "vue";
+import { DC, EntityShape } from "../../helper/deconstruction";
+import { Shape } from "konva/lib/Shape";
+import { Transformer } from "../Transformer";
+import { installGlobalVar, useMode } from "./use-global-vars.ts";
+import { Mode } from "../../constant/mode.ts";
+import { Transform } from "konva/lib/Util";
+import { Pos } from "@/draw/utils/math.ts";
+import { useConversionPosition } from "./use-coversion-position.ts";
+import { getOffset, listener } from "@/draw/utils/event.ts";
+import { flatPositions, mergeFuns } from "@/draw/utils/shared.ts";
+import { Line } from "konva/lib/shapes/Line";
+import { setShapeTransform } from "@/draw/utils/shape.ts";
+
+export type TransformerExtends = Transformer &  { queueShapes: EntityShape[] }
+export const transformRectPadding = 10;
+export const useTransformer = installGlobalVar(() => {
+  const transformer = new Transformer({ 
+    borderStrokeWidth: 1, 
+    useSingleNodeRotation: true 
+  }) as TransformerExtends;
+  transformer.queueShapes = []
+  return transformer;
+}, Symbol("transformer"));
+
+
+export const useShapeDrag = (
+  shape: Ref<DC<Shape> | undefined>, 
+  transform = ref<Transform>()
+) => {
+  const mode = useMode();
+  const conversion = useConversionPosition(true);
+
+  const init = (shape: Shape) => {
+    const dom = shape.getStage()!.container();
+    const moveTransform = new Transform()
+    let start: Pos | undefined;
+
+    const enter = (position: Pos) => {
+      start = position
+      mode.push(Mode.update);
+    }
+    const leave = () => {
+      transform.value = void 0
+      mode.pop();
+      start = void 0;
+    }
+
+    shape.draggable(true);
+    shape.dragBoundFunc((cur, ev) => {
+      const end = conversion(getOffset(ev, dom));
+      if (!start) {
+        enter(end)
+      } else {
+        moveTransform.reset()
+        moveTransform.translate(end.x - start.x, end.y - start.y)
+        transform.value = shape.getTransform().copy().multiply(moveTransform);
+      }
+      return cur
+    });
+
+    shape.on("pointerdown.mouse-drag", (ev) => {
+      enter(conversion(getOffset(ev.evt)))
+    });
+
+    return mergeFuns([
+      () => {
+        shape.draggable(false);
+        shape.off("pointerdown.mouse-status");
+        start && leave()
+      },
+      listener(document.documentElement, "pointerup", () => {
+        start && leave()
+      }),
+    ]);
+  };
+
+  watch(
+    () => shape.value?.getStage(),
+    (shape, _, onCleanup) => {
+      shape && onCleanup(init(shape));
+    }
+  );
+  return transform;
+};
+
+const emptyFn = () => {}
+export const useShapeTransformer = <T extends Shape>(
+  shape: Ref<DC<T> | undefined>, 
+  replaceShape?: (transformer: TransformerExtends, shape: T) => ({ tempShape: T, destory: () => void })
+) => {
+  const dragShape = ref<DC<T>>()
+  const dragTransform = useShapeDrag(dragShape)
+  const transform = ref<Transform>()
+  const status = useMouseShapeStatus(shape);
+  const mode = useMode();
+  const transformer = useTransformer();
+
+  const init = ($shape: T) => mergeFuns(
+    watch(
+      () => status.value.hover,
+      (active, _, onCleanup) => {
+        const parent = $shape.parent;
+        if (!(active && parent)) return;
+        let repShape: T
+
+        const resetTransformer = () => {
+          if (replaceShape) {
+            const rep = replaceShape(transformer, $shape)
+            repShape = rep.tempShape
+            return rep.destory
+          } else if (!repShape) {
+            repShape = $shape
+            transformer.nodes([repShape])
+            transformer.queueShapes = [repShape]
+          }
+          return emptyFn
+        }
+        let resetDestory = resetTransformer()
+        dragShape.value = shape.value
+        parent.add(transformer);
+
+        const enter = () => {
+          resetDestory()
+          resetDestory = resetTransformer()
+          mode.push(Mode.update);
+        }
+        const leave = () => {
+          mode.pop()
+          transform.value = void 0;
+        }
+        
+        transformer.on("pointerdown.shapemer", enter);
+        transformer.on("transformend.shapemer", leave);
+        transformer.on("transform.shapemer", () => {
+          transform.value = repShape.getTransform().copy()
+        });
+
+        onCleanup(() => {
+          parent.add($shape);
+          resetDestory()
+          transformer.nodes([]);
+          transformer.queueShapes = []
+          transform.value && leave()
+          transformer.off('pointerdown.shapemer transformend.shapemer transform.shapemer')
+        });
+      },
+      { immediate: true }
+    )
+  )
+  watch(shape, (shape, _, onCleanup) => {
+    if (shape) {
+      onCleanup(init(shape.getStage()));
+    }
+  });
+  return transform;
+};
+
+export type LineTransformerData = {
+  points: Pos[],
+  attitude: number[]
+}
+export const useLineTransformer = (
+  shape: Ref<DC<Line> | undefined>,
+  init: LineTransformerData,
+  callback: (data: LineTransformerData) => void
+) => {
+  const data = { ...init }
+  let tempShape: Line
+  let inverAttitude: Transform
+  let attitude: Transform
+  let stableVs = data.points
+  let tempVs = data.points
+
+  const transform = useShapeTransformer(shape, (transformer, $shape) => {
+    attitude = new Transform(data.attitude);
+    inverAttitude = attitude.copy().invert()
+
+    // 将数据转回初始状态
+    const initVs = stableVs.map(v => inverAttitude.point(v))
+    tempShape = $shape.clone({ 
+      fill: 'rgb(0, 255, 0)', 
+      visible: false, 
+      points: flatPositions(initVs) 
+    }) as Line
+    // 恢复姿态
+    setShapeTransform(tempShape, attitude)
+
+    tempShape.visible(true)
+    $shape.opacity(0.3)
+
+    $shape.parent!.add(tempShape)
+    tempShape.zIndex($shape.getZIndex() - 1)
+    transformer.nodes([tempShape]);
+    transformer.queueShapes = [$shape]
+    return {
+      tempShape,
+      destory: () => {
+        tempShape.remove()
+        $shape.opacity(1)
+        console.log('destory?')
+      }
+    };
+  })
+
+  watch(() => shape.value?.getNode(), $shape => {
+    if ($shape) {
+      $shape.points(flatPositions(tempVs))
+    }
+  })
+
+  watch(transform, (current, prev) => {
+    if (current) {
+      // 顶点更新
+      const $shape = shape.value!.getNode()
+      const transfrom = current.copy().multiply(inverAttitude)
+      tempVs = stableVs.map(v => transfrom.point(v))
+      $shape.points(flatPositions(tempVs))
+    } else if (prev && tempShape) {
+      data.attitude = tempShape.getTransform().m;
+      data.points = stableVs = tempVs
+      callback(data)
+    }
+  })
+  return transform
+}

+ 72 - 0
src/draw/core/hook/use-viewer.ts

@@ -0,0 +1,72 @@
+import { Viewer } from "../viewer.ts";
+import { computed, ref } 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";
+
+export const useViewer = installGlobalVar(
+	() => {
+		const stage = useStage();
+		const viewer = new Viewer();
+		const interactive = useMode();
+		const transform = ref(new Transform())
+	
+		const init = (dom: HTMLDivElement) => {
+			const dragDestroy = dragListener(dom, ({end, prev}) => {
+				viewer.movePixel({x: end.x - prev.x, y: end.y - prev.y});
+			});
+			const scaleDestroy = scaleListener(dom, (info) => {
+				viewer.scalePixel(info.center, info.scale);
+			});
+			viewer.bus.on('transformChange', newTransform => {
+				transform.value = newTransform
+			})
+			transform.value = viewer.transform
+			return mergeFuns(dragDestroy, scaleDestroy);
+		};
+	
+		return {
+			var: {
+				transform: transform,
+				viewer,
+			},
+			onDestory: globalWatch(
+				() => stage.value && interactive.value === Mode.viewer,
+				(can, _, onCleanup) => {
+					if (can) {
+						const dom = stage.value!.getNode().container();
+						onCleanup(init(dom));
+					}
+				},
+				{immediate: true}
+			)
+		}
+	},
+	Symbol("viewer")
+)
+
+
+export const useViewerTransform = installGlobalVar(
+	() => {
+		const viewer = useViewer()
+		return viewer.transform
+	},
+	Symbol('viewTransform')
+)
+
+export const useViewerTransformConfig = () => {
+	const transform = useViewerTransform()
+	return computed(() => transform.value.decompose());
+}
+
+export const useViewerInvertTransform = () => {
+	const transform = useViewerTransform()
+	return computed(() => transform.value.copy().invert());
+}
+
+export const useViewerInvertTransformConfig = () => {
+	const transform = useViewerInvertTransform()
+	return computed(() => transform.value.decompose());
+}

+ 90 - 0
src/draw/core/renderer/draw-group.vue

@@ -0,0 +1,90 @@
+<template>
+	<ShapeComponent :data="item" v-for="item in tempItems" :key="item" addMode/>
+</template>
+
+<script setup lang="ts">
+import { ShapeType, DrawData, components } from "../components";
+import { ref, reactive, watch } from "vue";
+import { Interactive, InteractiveAction, useInteractiveAreas, useInteractiveDots } from "../hook/use-interactive.ts";
+import { mergeFuns } from "@/draw/utils/shared.ts";
+
+const props = defineProps<{ type: ShapeType }>()
+const emit = defineEmits<{ (e: 'addItems', v: DrawData[ShapeType]): void }>()
+
+const type = props.type as 'arrow'
+const obj = components[type]
+const ShapeComponent = (components[type] as any).TempComponent || components[type].Component
+const tempItems = ref<Required<DrawData>[typeof type]>([])
+const single = ['dots'].includes(obj.addMode);
+
+let gInteractive: Interactive
+if (obj.addMode === 'area') {
+	const interactive = useInteractiveAreas({shapeType: type})
+	// 每次拽结束都加组件
+	watch(() => interactive.messages, (areas) => {
+		if (areas.length === 0) return;
+		for (const area of areas) {
+			let item = obj.interactiveToData({area}, interactive.preset)
+			if (!item) continue;
+			item = reactive(item)
+			tempItems.value.push(item)
+
+			if (interactive.singleDone.value) continue;
+			const stop = mergeFuns(
+					watch(area, () => obj.interactiveFixData(item, {area}), {deep: true}),
+					watch(() => [interactive.singleDone.value, interactive.isRunning.value], () => stop())
+			)
+		}
+		interactive.consume(areas);
+	}, {immediate: true})
+	gInteractive = interactive
+} else {
+	// 多点确定组件,
+	const interactive = useInteractiveDots({shapeType: type})
+	let item: any;
+	watch(() => interactive.messages, (dots, _, ) => {
+		if (dots.length === 0) return;
+		for (const dot of dots) {
+			const ndx = interactive.getNdx(dot);
+			if (!item) {
+				item = obj.interactiveToData({dot: dots[0], ndx}, interactive.preset)
+				if (!item) continue;
+				item = reactive(item);
+				tempItems.value.push(item)
+			} else {
+				obj.interactiveFixData(item, {dot, ndx: ndx})
+			}
+			if (interactive.singleDone.value) continue;
+			const stop = mergeFuns(
+					watch(dot, () => obj.interactiveFixData(item, {dot, ndx}), {deep: true}),
+					watch(() => [interactive.singleDone.value, interactive.isRunning.value], () => {
+						if (!single) {
+							item = null
+						}
+						stop()
+					})
+			)
+		}
+		interactive.consume(dots);
+	})
+	gInteractive = interactive
+}
+
+if (gInteractive!) {
+	watch(gInteractive!.isRunning, (isRunning) => {
+		if (isRunning || tempItems.value.length === 0) return
+		console.log(gInteractive.singleDone.value)
+		if (!gInteractive.singleDone.value) {
+			if (single) {
+				obj.interactiveFixData(tempItems.value[0], { action: InteractiveAction.delete })
+			} else {
+				tempItems.value.pop()
+			}
+		}
+
+		// 消费结束,发送添加完毕数据,未消费的则取消
+		emit('addItems', tempItems.value)
+		tempItems.value = []
+	}, {flush: 'pre'})
+}
+</script>

+ 21 - 0
src/draw/core/renderer/group.vue

@@ -0,0 +1,21 @@
+<template>
+	<ShapeComponent
+			:data="item"
+			v-for="(item, i) in items"
+			@update="value => $emit('updateItem', { ndx: i, value })"
+	/>
+</template>
+
+<script setup lang="ts">
+import { ShapeType, DrawData, components, DrawItem } from "../components";
+import { computed } from "vue";
+
+const props = defineProps<{ type: ShapeType, data: DrawData }>()
+const type = props.type as 'arrow'
+const ShapeComponent = components[type].Component
+const items = computed(() => props.data[type] || [])
+
+defineEmits<{ (e: 'updateItem', data: {value: DrawItem, ndx: number}): void }>()
+
+
+</script>

+ 62 - 0
src/draw/core/renderer/renderer.vue

@@ -0,0 +1,62 @@
+<template>
+	<v-stage ref="stage" :config="size">
+		<v-layer :config="viewerConfig" id="formal">
+			<!--	不可去除,去除后移动端拖拽会有溢出	-->
+			<v-rect :config="{ ...size, fill: 'rgba(0,0,0,0)', listener: false, ...invertViewerConfig }"/>
+			<ShapeGroup
+					v-for="type in types"
+					:type="type"
+					:key="type"
+					:data="data"
+					@updateItem="playData => updateHandler(type, playData)"
+			/>
+		</v-layer>
+		<!--	临时组,提供临时绘画	-->
+		<v-layer :config="viewerConfig" id="temp">
+			<TempShapeGroup v-for="type in types" :type="type" :key="type"
+											@add-items="items => addItemsHandler(type, items)"/>
+		</v-layer>
+	</v-stage>
+</template>
+
+<script lang="ts" setup>
+import ShapeGroup from './group.vue'
+import TempShapeGroup from './draw-group.vue'
+import { ShapeType, components, DrawItem } from "../components";
+import { useStage } from "../hook/use-global-vars.ts";
+import {
+	useViewerInvertTransformConfig,
+	useViewerTransformConfig
+} from "../hook/use-viewer.ts";
+import { useListener, useResize } from "../hook/use-event.ts";
+import { useExpose } from "../hook/use-expose.ts";
+import { useInteractiveShapeAPI } from '../hook/use-interactive.ts'
+import { data } from '../store/index'
+
+const stage = useStage();
+const size = useResize()
+const viewerConfig = useViewerTransformConfig()
+const invertViewerConfig = useViewerInvertTransformConfig()
+
+const types = Object.keys(components) as ShapeType[]
+
+const addItemsHandler = (type: ShapeType, items: any) => {
+	if (!data[type]) {
+		data[type] = [];
+	}
+	console.log(JSON.stringify(items, null, 2))
+	data[type].push(...items)
+}
+
+const updateHandler = (type: ShapeType, playData: {value: DrawItem, ndx: number}) => {
+	data[type][playData.ndx] = playData.value
+}
+
+const {quitMouseAddShape} = useInteractiveShapeAPI()
+useListener('contextmenu', ev => {
+	ev.preventDefault();
+	quitMouseAddShape()
+}, document.documentElement)
+
+defineExpose(useExpose())
+</script>

+ 183 - 0
src/draw/core/store/index.ts

@@ -0,0 +1,183 @@
+import { reactive } from "vue";
+
+export const data = reactive({
+	"image": [
+		// {
+		// 	"url": "/src/assets/WX20241031-111850.png",
+		// 	"width": 1,
+		// 	"height": 1,
+		// 	"x": 594.5,
+		// 	"y": 436
+		// }
+	],
+	"rectangle": [
+		{
+			"attitude": [1, 0, 0, 1, 0, 0],
+			"points": [
+				{
+					"x": 944.984375,
+					"y": 138.04296875
+				},
+				{
+					"x": 1120.96484375,
+					"y": 138.04296875
+				},
+				{
+					"x": 1120.96484375,
+					"y": 332.5234375
+				},
+				{
+					"x": 944.984375,
+					"y": 332.5234375
+				}
+			]
+		}
+	],
+	"triangle": [
+		{
+			"points": [
+				{
+					"x": 115.58984375,
+					"y": 626.625
+				},
+				{
+					"x": 229.76953125,
+					"y": 495.828125
+				},
+				{
+					"x": 343.94921875,
+					"y": 626.625
+				}
+			]
+		}
+	],
+	"polygon": [
+		{
+			"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": [
+		{
+			"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": [
+		{
+			"points": [
+				{
+					"x": 962.1484375,
+					"y": 129.10546875
+				},
+				{
+					"x": 744.4921875,
+					"y": 244.49609375
+				}
+			]
+		},
+		{
+			"points": [
+				{
+					"x": 756.35546875,
+					"y": 94.78125
+				},
+				{
+					"x": 879.94140625,
+					"y": 265.87109375
+				}
+			]
+		}
+	],
+	"circle": [
+		{
+			"x": 1372.22265625,
+			"y": 100.3671875,
+			"radius": 84.72265625
+		}
+	],
+	"icon": [
+		{
+			"url": "/src/assets/icons/vue.svg",
+			"x": 908.390625,
+			"y": 454.0390625
+		},
+		{
+			"url": "/src/assets/icons/vue.svg",
+			"x": 1049.234375,
+			"y": 442.33984375
+		},
+		{
+			"url": "/src/assets/icons/BedsideCupboard.svg",
+			"stroke": "red",
+			"strokeWidth": 1,
+			"strokeScaleEnabled": false,
+			"x": 1209.515625,
+			"y": 446.01171875
+		},
+		{
+			"url": "/src/assets/icons/BedsideCupboard.svg",
+			"stroke": "red",
+			"strokeWidth": 1,
+			"strokeScaleEnabled": false,
+			"x": 1417.73046875,
+			"y": 449.8046875
+		}
+	],
+	"text": [
+		{
+			"fill": "#000",
+			"stroke": "red",
+			"strokeWidth": 3,
+			"fontFamily": "Calibri",
+			"fontSize": 30,
+			"maxWidth": 300,
+			"content": "文字",
+			"x": 484.13671875,
+			"y": 663.13671875
+		},
+		{
+			"fill": "#000",
+			"stroke": "red",
+			"strokeWidth": 3,
+			"fontFamily": "Calibri",
+			"fontSize": 30,
+			"maxWidth": 300,
+			"content": "文字",
+			"x": 873.94921875,
+			"y": 659.453125
+		}
+	],
+})

+ 132 - 0
src/draw/core/viewer.ts

@@ -0,0 +1,132 @@
+import { Transform } from "konva/lib/Util";
+import { Pos } from "../utils/math";
+import { alignPortMat } from "@/draw/utils/align-port.ts";
+import mitt from 'mitt'
+
+export type ViewerProps = {
+	size?: number[];
+	bound?: number[];
+	padding: number | number[];
+	retain: boolean;
+};
+
+export class Viewer {
+	props: ViewerProps;
+	viewMat: Transform;
+	partMat: Transform = new Transform();
+	bus = mitt<{ transformChange: Transform }>()
+
+	constructor(props: Partial<ViewerProps> = {}) {
+		this.props = {
+			padding: 0,
+			retain: true,
+			...props,
+		}
+		this.viewMat = new Transform();
+	}
+
+	get bound() {
+		return this.props.bound
+	}
+
+	setBound(bound: number[], size?: number[], padding?: number | number[], retain?: boolean) {
+		this.props.bound = bound
+		if (padding) {
+			this.props.padding = padding
+		}
+		if (retain) {
+			this.props.retain = retain
+		}
+		if (size) {
+			this.props.size = size
+		}
+		padding = this.props.padding
+		retain = this.props.retain
+		size = this.props.size
+
+		if (!size) {
+			throw '缺少视窗size'
+		}
+
+		this.partMat = alignPortMat(
+			[
+				{x: bound[0], y: bound[1]},
+				{x: bound[2], y: bound[3]},
+			],
+			[
+				{x: 0, y: 0},
+				{x: size[0], y: size[1]},
+			],
+			retain,
+			typeof padding === "number"
+				? padding
+				: {x: padding[0], y: padding[1]}
+		);
+	}
+
+	move(position: Pos, initMat = this.viewMat) {
+		this.mutMat(new Transform().translate(position.x, position.y), initMat);
+	}
+
+	movePixel(position: Pos, initMat = this.viewMat) {
+		const info = initMat.decompose()
+		const tf = new Transform()
+		tf.rotate(info.rotation)
+		tf.scale(info.scaleX, info.scaleY)
+		this.move(tf.invert().point(position), this.viewMat)
+	}
+
+
+	scale(center: Pos, scale: number, initMat = this.viewMat) {
+		this.mutMat(
+			new Transform()
+				.translate(center.x, center.y)
+				.multiply(
+					new Transform()
+						.scale(scale, scale)
+						.multiply(new Transform().translate(-center.x, -center.y))
+				),
+			initMat
+		);
+	}
+
+	scalePixel(center: Pos, scale: number, initMat = this.viewMat) {
+		const pos = initMat.copy().invert().point(center)
+		this.scale(pos, scale, initMat)
+	}
+
+	rotate(center: Pos, angleRad: number, initMat = this.viewMat) {
+		this.mutMat(
+			new Transform()
+				.translate(center.x, center.y)
+				.multiply(
+					new Transform()
+						.rotate(angleRad)
+						.multiply(new Transform().translate(-center.x, -center.y))
+				),
+			initMat
+		);
+	}
+
+	rotatePixel(center: Pos, angleRad: number, initMat = this.viewMat) {
+		const pos = initMat.copy().invert().point(center)
+		this.rotate(pos, angleRad, initMat)
+	}
+
+	mutMat(mat: Transform, initMat = this.viewMat) {
+		this.setViewMat(initMat.copy().multiply(mat))
+	}
+
+	setViewMat(mat: number[] | Transform) {
+		if (mat instanceof Transform) {
+			this.viewMat = mat.copy();
+		} else {
+			this.viewMat = new Transform(mat)
+		}
+		this.bus.emit('transformChange', this.transform)
+	}
+
+	get transform() {
+		return this.partMat.copy().multiply(this.viewMat);
+	}
+}

+ 8 - 0
src/draw/helper/deconstruction.d.ts

@@ -0,0 +1,8 @@
+import Konva from "konva";
+
+type DC<T extends any> = {
+	getNode: () => T,
+	getStage: () => T
+}
+
+type EntityShape = Konva.Shape | Konva.Stage | Konva.Layer | Konva.Group

+ 14 - 0
src/draw/index.ts

@@ -0,0 +1,14 @@
+export { default as Renderer } from './core/renderer/renderer.vue'
+export { components } from './core/components'
+
+import { components } from './core/components'
+
+export type ShapeType = keyof typeof components
+export const shapeNames = {} as Record<ShapeType, string>
+
+for (const key in components) {
+	shapeNames[key as ShapeType] = components[key as ShapeType].shapeName
+}
+
+export type { DrawExpose } from './core/hook/use-expose'
+export type { PresetAdd } from './core/hook/use-global-vars.ts'

+ 36 - 0
src/draw/utils/align-port.ts

@@ -0,0 +1,36 @@
+import { Transform } from "konva/lib/Util";
+import { vector, Pos } from "./math";
+
+/**
+ * 创建对齐端口矩阵
+ * @param targetView 目标窗口左上右下坐标
+ * @param originView 源窗口左上右下坐标
+ * @param retainScale 是否固定xy的缩放系数
+ * @param padding 留白区域
+ * @returns
+ */
+export const alignPortMat = (
+	targetView: Pos[],
+	originView: Pos[],
+	retainScale = false,
+	padding: Pos | number = 0
+) => {
+	padding = typeof padding === "number" ? { x: padding, y: padding } : padding;
+
+	const pad = vector(padding);
+	const real = vector(originView[1]).sub(originView[0]);
+	const screen = vector(targetView[1]).sub(targetView[0]);
+	const scale = screen.clone().sub(pad.multiplyScalar(2)).divide(real);
+
+	if (retainScale) {
+		scale.x = scale.y = Math.min(scale.x, scale.y); // 选择较小的比例以保持内容比例
+	}
+
+	const offset = screen
+		.clone()
+		.sub(real.clone().multiply(scale))
+		.divideScalar(2)
+		.sub(real.clone().multiply(scale));
+
+	return new Transform().translate(offset.x, offset.y).scale(scale.x, scale.y);
+};

+ 37 - 0
src/draw/utils/colors.ts

@@ -0,0 +1,37 @@
+import { Color, HSL } from "three";
+
+const offset = {
+  hover: {
+    h: -0.012,
+    s: 0,
+    l: 0.07,
+  },
+  press: {
+    h: 0.002,
+    s: 0.104,
+    l: 0.207,
+  },
+  disable: {
+    h: -0.0189,
+    s: 0,
+    l: -0.15,
+  },
+};
+type keys = keyof typeof offset;
+
+export const getMouseColors = (color16: string) => {
+  const theme = new Color(color16);
+  const themeHSL = theme.getHSL({} as HSL);
+  const temp = new Color();
+  const colors = Object.entries(offset).reduce((t, [k, o]) => {
+    t[k as keys] = '#' + temp
+      .setHSL(themeHSL.h + o.h, themeHSL.s + o.s, themeHSL.l + o.l)
+      .getHexString();
+    return t;
+  }, {} as Record<keys, string>);
+
+  return {
+    theme: '#' + theme.getHexString(),
+    ...colors
+  };
+}

+ 176 - 0
src/draw/utils/event.ts

@@ -0,0 +1,176 @@
+import { lineLen, Pos } from "@/draw/utils/math.ts";
+import { mergeFuns } from "@/draw/utils/shared.ts";
+
+export const listener = <T extends HTMLElement | Window, K extends keyof HTMLElementEventMap>(
+	target: T,
+	eventName: K,
+	callback: (this: T, ev: HTMLElementEventMap[K]) => any
+) => {
+	target.addEventListener(eventName, callback as any)
+	return () => {
+		target.removeEventListener(eventName, callback as any)
+	}
+}
+
+export const clickListener = (dom: HTMLDivElement, callback: (position: Pos, ev: PointerEvent) => void) => {
+	let downTime = 0;
+	let move = false
+	return dragListener(dom, {
+		down(_, ev) {
+			if (ev.button !== 0) return;
+			downTime = Date.now();
+		},
+		up(position, ev) {
+			const prevMove = move
+			move = false
+			if (prevMove || !downTime) return;
+			if (Date.now() - downTime <= 300) {
+				callback(position, ev)
+			}
+			downTime = 0
+		},
+	});
+};
+
+
+type DragProps = {
+	move?: (info: Record<'start' | 'prev' | 'end', Pos> & {ev: PointerEvent}) => void,
+	down?: (pos: Pos, ev: PointerEvent) => void,
+	up?: (pos: Pos, ev: PointerEvent) => void,
+}
+export const dragListener = (dom: HTMLElement, props: DragProps | DragProps['move'] = {}) => {
+	if (typeof props === 'function') {
+		props = { move: props }
+	}
+	const { move, up, down } = props
+	const mount = document.documentElement
+
+	if (!move && !up && !down) return () => {}
+
+	let moveHandler: any, endHandler: any
+	const downHandler = (ev: PointerEvent) => {
+		const start = getOffset(ev, dom)
+		let prev = start
+		down && down(start, ev)
+		ev.preventDefault();
+
+		moveHandler = (ev: PointerEvent) => {
+			const end = getOffset(ev, dom)
+			move!({start, end, prev, ev})
+			prev = end
+
+			ev.preventDefault();
+		}
+		endHandler = (ev: PointerEvent) => {
+			up && up(getOffset(ev, dom), ev)
+			mount.removeEventListener('pointermove', moveHandler);
+			mount.removeEventListener('pointerup', endHandler);
+			ev.preventDefault();
+		}
+	
+		
+		move && mount.addEventListener('pointermove', moveHandler, { passive: false })
+		mount.addEventListener('pointerup', endHandler, { passive: false })
+	}
+
+	dom.addEventListener('pointerdown', downHandler, { passive: false });
+	return () => {
+		dom.removeEventListener('pointerdown', downHandler);
+		moveHandler && mount.removeEventListener('pointermove', moveHandler);
+		endHandler && mount.removeEventListener('pointerup', endHandler);
+	}
+}
+
+
+
+
+export const getTouchScaleProps = (ev: TouchEvent, dom = ev.target! as HTMLElement) => {
+	const start = getOffset(ev,  dom, 0);
+	const end = getOffset(ev, dom, 1);
+	const center = {
+		x: (end.x + start.x) / 2,
+		y: (end.y + start.y) / 2,
+	};
+	const initDist = lineLen(start, end);
+	return {
+		center,
+		dist: initDist,
+	};
+};
+
+export const getOffset = (ev: MouseEvent | TouchEvent, dom = ev.target! as HTMLElement, ndx = 0) => {
+	const event = ev instanceof TouchEvent ? ev.changedTouches[ndx] : ev
+	const rect = dom.getBoundingClientRect();
+	const offsetX = event.clientX - rect.left;
+	const offsetY = event.clientY - rect.top;
+	return {
+		x: offsetX,
+		y: offsetY,
+	};
+};
+
+
+export const touchScaleListener = (
+	dom: HTMLElement,
+	cb: (props: { center: Pos; scale: number }) => void
+) => {
+	const mount = document.documentElement
+	let moveHandler: (ev: TouchEvent) => void
+	let endHandler: (ev: TouchEvent) => void
+	const startHandler = (ev: TouchEvent) => {
+		if (ev.changedTouches.length <= 1) return;
+		let prevScale = getTouchScaleProps(ev, dom);
+		ev.preventDefault();
+
+		moveHandler = (ev: TouchEvent) => {
+			if (ev.changedTouches.length <= 1) return;
+			const curScale = getTouchScaleProps(ev, dom);
+			cb({center: prevScale.center, scale: curScale.dist / prevScale.dist});
+			prevScale = curScale;
+			ev.preventDefault();
+		};
+		endHandler = (ev: TouchEvent) => {
+			mount.removeEventListener("touchmove", moveHandler);
+			mount.removeEventListener("touchend", endHandler);
+			ev.preventDefault();
+		};
+
+		mount.addEventListener("touchmove", moveHandler, {
+			passive: false,
+		});
+		mount.addEventListener("touchend", endHandler, {
+			passive: false,
+		});
+	};
+
+	dom.addEventListener("touchstart", startHandler, {passive: false});
+
+	return () => {
+		dom.removeEventListener("touchstart", startHandler);
+		mount.removeEventListener("touchmove", moveHandler);
+		mount.removeEventListener("touchend", endHandler);
+	};
+};
+
+
+export const wheelListener = (
+	dom: HTMLElement,
+	cb: (props: { center: Pos; scale: number }) => void
+) => {
+	const wheelHandler = (ev: WheelEvent) => {
+		const scale = 1 - ev.deltaY / 1000;
+		const center = {x: ev.offsetX, y: ev.offsetY};
+		cb({center, scale});
+		ev.preventDefault();
+	};
+
+	dom.addEventListener("wheel", wheelHandler);
+	return () => {
+		dom.removeEventListener("wheel", wheelHandler);
+	};
+};
+
+export const scaleListener = (
+	dom: HTMLElement,
+	cb: (props: { center: Pos; scale: number }) => void
+) => mergeFuns(touchScaleListener(dom, cb), wheelListener(dom, cb));

+ 362 - 0
src/draw/utils/math.ts

@@ -0,0 +1,362 @@
+import { Vector2, ShapeUtils, Box2 } from "three";
+import { Transform } from "konva/lib/Util";
+import { round } from "./shared";
+
+export type Pos = { x: number; y: number };
+
+export const vector = (pos: Pos) => new Vector2(pos.x, pos.y);
+export const lVector = (line: Pos[]) => line.map(vector);
+
+export const vsBound = (positions: Pos[]) => {
+	const box = new Box2()
+	box.setFromPoints(positions.map(vector))
+	return box
+}
+
+/**
+ * 获取线段方向
+ * @param line 线段
+ * @returns 方向
+ */
+export const lineVector = (line: Pos[]) =>
+	vector(line[1]).sub(vector(line[0])).normalize();
+
+const epsilon = 1e-6; // 误差范围
+
+/**
+ * 点是否相同
+ * @param p1 点1
+ * @param p2 点2
+ * @returns 是否相等
+ */
+export const eqPoint = (p1: Pos, p2: Pos) =>
+	vector(p1).distanceTo(p2) < epsilon;
+
+/**
+ * 获取两点距离
+ * @param p1 点1
+ * @param p2 点2
+ * @returns 距离
+ */
+export const lineLen = (p1: Pos, p2: Pos) => vector(p1).distanceTo(p2);
+
+export const vectorLen = (dire: Pos) => vector(dire).length();
+
+/**
+ * 获取向量的垂直向量
+ * @param dire 原方向
+ * @returns 垂直向量
+ */
+export const verticalVector = (dire: Pos) =>
+	vector({ x: -dire.y, y: dire.x }).normalize();
+
+/**
+ * 获取旋转指定度数后的向量
+ * @param pos 远向量
+ * @param angleRad 旋转角度
+ * @returns 旋转后向量
+ */
+export const rotateVector = (pos: Pos, angleRad: number) =>
+	new Transform().rotate(angleRad).point(pos);
+
+/**
+ * 创建线段
+ * @param dire 向量
+ * @param start 起始点
+ * @param dis 长度
+ * @returns 线段
+ */
+
+export const getVectorLine = (
+	dire: Pos,
+	start: Pos = { x: 0, y: 0 },
+	dis: number = 1
+) => [start, vector(dire).multiplyScalar(dis).add(start)];
+
+/**
+ * 获取线段的垂直方向向量
+ * @param line 原线段
+ * @returns 垂直向量
+ */
+export const lineVerticalVector = (line: Pos[]) =>
+	verticalVector(lineVector(line));
+
+/**
+ * 获取向量的垂直线段
+ * @param dire 向量
+ * @param start 线段原点
+ * @param len 线段长度
+ */
+export const verticalVectorLine = (
+	dire: Pos,
+	start: Pos = { x: 0, y: 0 },
+	len: number = 1
+) => getVectorLine(verticalVector(dire), start, len);
+
+/**
+ * 获取两向量角度(从向量a出发)
+ * @param v1 向量a
+ * @param v2 向量b
+ * @returns 两向量夹角弧度, 逆时针为正,顺时针为负
+ */
+export const vector2IncludedAngle = (v1: Pos, v2: Pos) => {
+	const start = vector(v1);
+	const end = vector(v2);
+	const angle = start.angleTo(end);
+	return start.cross(end) > 0 ? angle : -angle;
+};
+
+/**
+ * 获取两线段角度(从线段a出发)
+ * @param line1 线段a
+ * @param line2 线段b
+ * @returns 两线段夹角弧度
+ */
+export const line2IncludedAngle = (line1: Pos[], line2: Pos[]) =>
+	vector2IncludedAngle(lineVector(line1), lineVector(line2));
+
+/**
+ * 获取线段与方向的夹角弧度
+ * @param line 线段
+ * @param dire 方向
+ * @returns 线段与方向夹角弧度
+ */
+export const lineAndVectorIncludedAngle = (line: Pos[], v: Pos) =>
+	vector2IncludedAngle(lineVector(line), v);
+
+/**
+ * 获取线段中心点
+ * @param line
+ * @returns
+ */
+export const lineCenter = (line: Pos[]) =>
+	vector(line[0]).add(line[1]).multiplyScalar(0.5);
+
+
+export const pointsCenter = (points: Pos[]) => {
+	if (points.length === 0) return {x: 0, y: 0}
+	const v = vector(points[0])
+	for (let i = 1; i < points.length; i++) {
+		v.add(points[i])
+	}
+	return v.multiplyScalar(1/points.length)
+}
+
+export const lineJoin = (l1: Pos[], l2: Pos[]) => {
+	const checks = [
+		[l1[0], l2[0]],
+		[l1[0], l2[1]],
+		[l1[1], l2[0]],
+		[l1[1], l2[1]],
+	];
+	const ndx = checks.findIndex((line) => eqPoint(line[0], line[1]));
+	if (~ndx) {
+		return checks[ndx];
+	} else {
+		return false;
+	}
+};
+
+export const isLineEqual = (l1: Pos[], l2: Pos[]) =>
+	eqPoint(l1[0], l2[0]) && eqPoint(l1[1], l2[1]);
+
+export const isLineReverseEqual = (l1: Pos[], l2: Pos[]) =>
+	eqPoint(l1[0], l2[1]) && eqPoint(l1[1], l2[0]);
+
+export const isLineIntersect = (l1: Pos[], l2: Pos[]) => {
+	const s1 = l2[1].y - l2[0].y;
+	const s2 = l2[1].x - l2[0].x;
+	const s3 = l1[1].x - l1[0].x;
+	const s4 = l1[1].y - l1[0].y;
+	const s5 = l1[0].y - l2[0].y;
+	const s6 = l1[0].x - l2[0].x;
+
+	const denominator = s1 * s3 - s2 * s4;
+	const ua = round((s2 * s5 - s1 * s6) / denominator, 6);
+	const ub = round((s3 * s5 - s4 * s6) / denominator, 6);
+
+	if (ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1) {
+		return true;
+	} else {
+		return false;
+	}
+};
+
+export const lineParallelRelationship = (l1: Pos[], l2: Pos[]) => {
+	const dire1 = lineVector(l1);
+	const dire2 = lineVector(l2);
+
+	// 计算线段的法向量
+	const normal1 = verticalVector(dire1);
+	const normal2 = verticalVector(dire2);
+	const startDire = lineVector([l1[0], l2[0]]);
+
+	// 计算线段的参数方程
+	const t1 = round(normal2.dot(startDire) / normal2.dot(dire1), 6);
+	const t2 = round(normal1.dot(startDire) / normal1.dot(dire2), 6);
+
+	if (t1 === 0 && t2 === 0) {
+		return RelationshipEnum.Overlap;
+	}
+
+	if (eqPoint(normal1, normal2) || eqPoint(normal1, normal2.clone().negate())) {
+		return lineJoin(l1, l2)
+			? RelationshipEnum.Overlap
+			: RelationshipEnum.Parallel;
+	}
+};
+
+export enum RelationshipEnum {
+	// 重叠
+	Overlap = "Overlap",
+	// 相交
+	Intersect = "Intersect",
+	// 延长相交
+	ExtendIntersect = "ExtendIntersect",
+	// 平行
+	Parallel = "Parallel",
+	// 首尾连接
+	Join = "Join",
+	// 一样
+	Equal = "Equal",
+	// 反向
+	ReverseEqual = "ReverseEqual",
+}
+/**
+ * 获取两线段是什么关系,(重叠、相交、平行、首尾相接等)
+ * @param l1
+ * @param l2
+ * @returns RelationshipEnum
+ */
+export const lineRelationship = (l1: Pos[], l2: Pos[]) => {
+	if (isLineEqual(l1, l2)) {
+		return RelationshipEnum.Equal;
+	} else if (isLineReverseEqual(l1, l2)) {
+		return RelationshipEnum.ReverseEqual;
+	}
+
+	const parallelRelationship = lineParallelRelationship(l1, l2);
+	if (parallelRelationship) {
+		return parallelRelationship;
+	} else if (lineJoin(l1, l2)) {
+		return RelationshipEnum.Join;
+	} else if (isLineIntersect(l1, l2)) {
+		return RelationshipEnum.Intersect; // 两线段相交
+	} else {
+		return RelationshipEnum.ExtendIntersect; // 延长可相交
+	}
+};
+
+/**
+ * 获取两线段交点,可延长相交
+ * @param l1 线段1
+ * @param l2 线段2
+ * @returns 交点坐标
+ */
+export const lineIntersection = (l1: Pos[], l2: Pos[]) => {
+	// 定义两条线段的起点和终点坐标
+	const [line1Start, line1End] = lVector(l1);
+	const [line2Start, line2End] = lVector(l2);
+
+	// 计算线段的方向向量
+	const dir1 = line1End.clone().sub(line1Start);
+	const dir2 = line2End.clone().sub(line2Start);
+
+	// 计算参数方程中的系数
+	const a = dir1.x;
+	const b = -dir2.x;
+	const c = dir1.y;
+	const d = -dir2.y;
+
+	const e = line2Start.x - line1Start.x;
+	const f = line2Start.y - line1Start.y;
+
+	// 求解参数t和s
+	const t = (d * e - b * f) / (a * d - b * c);
+	// 计算交点坐标
+	return line1Start.clone().add(dir1.clone().multiplyScalar(t)).toArray();
+};
+
+/**
+ * 获取点在线段上的投影
+ * @param line 线段
+ * @param position 点
+ * @returns 投影信息
+ */
+export const linePointProjection = (line: Pos[], position: Pos) => {
+	// 定义线段的起点和终点坐标
+	const [lineStart, lineEnd] = lVector(line);
+	// 定义一个点的坐标
+	const point = vector(position);
+
+	// 计算线段的方向向量
+	const lineDir = lineEnd.clone().sub(lineStart);
+	// 计算点到线段起点的向量
+	const pointToLineStart = point.clone().sub(lineStart);
+	// 计算点在线段方向上的投影长度
+	const t = pointToLineStart.dot(lineDir.normalize());
+	// 计算投影点的坐标
+	return lineStart.add(lineDir.multiplyScalar(t));
+};
+
+/**
+ * 获取点距离线段最近距离
+ * @param line 直线
+ * @param position 参考点
+ * @returns 距离
+ */
+export const linePointLen = (line: Pos[], position: Pos) =>
+	lineLen(position, linePointProjection(line, position));
+
+/**
+ * 计算多边形是否为逆时针
+ * @param points 多边形顶点
+ * @returns true | false
+ */
+export const isPolygonCounterclockwise = (points: Pos[]) =>
+	ShapeUtils.isClockWise(points.map(vector));
+
+/**
+ * 切割线段,返回连段切割点
+ * @param line 线段
+ * @param amount 切割份量
+ * @param unit 一份单位大小
+ * @returns 点数组
+ */
+export const lineSlice = (
+	line: Pos[],
+	amount: number,
+	unit = lineLen(line[0], line[1]) / amount
+) =>
+	new Array(unit)
+		.fill(0)
+		.map((_, i) => linePointProjection(line, { x: i * unit, y: i * unit }));
+
+/**
+ * 线段是否相交多边形
+ * @param polygon 多边形
+ * @param line 检测线段
+ * @returns
+ */
+export const isPolygonLineIntersect = (polygon: Pos[], line: Pos[]) => {
+	for (let i = 0; i < polygon.length; i++) {
+		if (isLineIntersect([polygon[i], polygon[i + 1]], line)) {
+			return true;
+		}
+	}
+	return false;
+};
+
+/**
+ * 通过角度和两个点获取两者的连接点,
+ * @param p1
+ * @param p2
+ * @param rad
+ */
+export const joinPoint = (p1: Pos, p2: Pos, rad: number) => {
+	const lvector = new Vector2()
+		.subVectors(p1, p2)
+		.rotateAround({ x: 0, y: 0 }, rad);
+
+	return vector(p2).add(lvector);
+};

+ 85 - 0
src/draw/utils/resource.ts

@@ -0,0 +1,85 @@
+
+
+let imageCache: Record<string, Promise<HTMLImageElement>>
+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)
+}
+
+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);
+}
+
+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";
+
+	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)
+
+			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)
+}

+ 59 - 0
src/draw/utils/shape.ts

@@ -0,0 +1,59 @@
+import { Transform } from "konva/lib/Util";
+import { EntityShape } from "../helper/deconstruction";
+
+
+export const shapeTreeEq = (
+	parent: EntityShape,
+	eq: (shape: EntityShape) => boolean,
+	checked: EntityShape[] = []
+) => {
+	if (checked.includes(parent)) return null;
+	if (eq(parent)) {
+		return parent;
+	}
+
+	if ("children" in parent) {
+		for (const child of parent.children) {
+			const e = shapeTreeEq(child, eq, checked);
+			if (e) {
+				return child;
+			}
+		}
+	}
+
+	return null;
+};
+
+export const shapeParentsEq = (
+	target: EntityShape,
+	eq: (shape: EntityShape) => boolean,
+	checked: EntityShape[] = []
+) => {
+	while (target) {
+		if (checked.includes(target)) return null;
+		if (eq(target)) {
+			return target;
+		}
+		target = target.parent as any;
+	}
+	return null;
+};
+
+export const shapeTreeContain = (
+	parent: EntityShape | EntityShape[],
+	target: EntityShape,
+	checked: EntityShape[] = []
+) => {
+	const eq = Array.isArray(parent)
+		? (shape: EntityShape) => parent.includes(shape)
+		: (shape: EntityShape) => parent === shape;
+	return shapeParentsEq(target, eq, checked);
+};
+
+
+export const setShapeTransform = (shape: EntityShape, transform: Transform) => {
+	const config = transform.decompose()
+	for (const key in config) {
+		(shape as any)[key]((config as any)[key])
+	}
+}

+ 163 - 0
src/draw/utils/shared.ts

@@ -0,0 +1,163 @@
+import { Pos } from "@/draw/utils/math.ts";
+import { v4 as uuid } from 'uuid'
+
+/**
+ * 四舍五入
+ * @param num
+ * @param b 保留位数
+ * @returns
+ */
+export const round = (num: number, b: number = 2) => {
+	const scale = Math.pow(10, b);
+	return Math.round(num * scale) / scale;
+};
+
+/**
+ * 范围取余
+ * @param num
+ * @param mod
+ */
+export const rangMod = (num: number, mod: number) => ((num % mod) + mod) % mod;
+
+/**
+ * 有偏差的indexOf
+ * @param arr
+ * @param warp
+ * @param val
+ * @returns
+ */
+export const warpIndexOf = (arr: number[], warp: number, val: number) =>
+	arr.findIndex((num) => val >= num - warp && val <= num + warp);
+
+/**
+ * 多个函数合并成一个函数
+ * @param fns
+ * @returns
+ */
+export const mergeFuns = (...fns: (() => void)[] | (() => void)[][]) => {
+	return () => {
+		fns.forEach((fn) => {
+			if (Array.isArray(fn)) {
+				fn.forEach((f) => f());
+			} else {
+				fn();
+			}
+		});
+	};
+};
+
+/**
+ * 获取数据类型
+ * @param value
+ * @returns
+ */
+export const toRawType = (value: unknown): string =>
+	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;
+	}
+};
+
+/**
+ * 查看数据是否被修改
+ * @param raw1
+ * @param raw2
+ * @returns
+ */
+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
+) => {
+	let timeout: any;
+	return function (...args: Parameters<T>) {
+		clearTimeout(timeout);
+		timeout = setTimeout(() => {
+			fn.apply(null, args);
+		}, delay);
+	};
+};
+
+/**
+ * 获取数据变化
+ * @param newIds
+ * @param oldIds
+ * @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));
+
+	return {addPort, delPort, holdPort};
+};
+
+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()

+ 13 - 0
src/main.ts

@@ -0,0 +1,13 @@
+import { createApp } from 'vue'
+import './styles/global.scss'
+import VueKonva from "vue-konva";
+import App from './App.vue'
+import { pinia } from './store'
+import { version } from '@/../package.json'
+
+const app = createApp(App)
+app.use(pinia)
+app.use(VueKonva)
+app.mount('#app')
+
+console.log('当前版本', version)

+ 3 - 0
src/store/index.ts

@@ -0,0 +1,3 @@
+import { createPinia } from 'pinia'
+
+export const pinia = createPinia()

+ 8 - 0
src/styles/element.scss

@@ -0,0 +1,8 @@
+@forward 'element-plus/theme-chalk/src/common/var.scss' with (
+  $colors: (
+    'primary': (
+      'base': #D8000A,
+    ),
+  ),
+ $common-component-size: ('default': 40px)
+);

+ 33 - 0
src/styles/global.scss

@@ -0,0 +1,33 @@
+@use 'element-plus/theme-chalk/src/common/var.scss';
+
+$headerSize: 70px;
+$slideSize: 100px;
+
+* {
+	box-sizing: border-box;
+	padding: 0;
+	margin: 0;
+}
+
+.operate {
+	transition: color .3s ease;
+	cursor: pointer;
+
+	&:hover {
+		color: var.$color-primary;
+	}
+}
+
+.center {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+}
+
+.i-center {
+	display: inline-flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+}

+ 83 - 0
src/views/header/funds.ts

@@ -0,0 +1,83 @@
+import { useAction } from "@/views/use-draw.ts";
+
+export const revoke = () => {
+
+}
+
+export const recover = () => {
+
+}
+
+export const clear = () => {
+
+}
+
+export const rotate = () => {
+
+}
+
+export const full = () => {
+
+}
+
+export const aiImport = () => {
+
+}
+
+export const setBGImage = () => {
+
+}
+
+export const gotoVR = () => {
+
+}
+
+export const saveData = () => {
+}
+
+export const exportData = () => {
+}
+
+export const gotoDrawing = () => {
+}
+
+import bgImage from '@/assets/WX20241031-111850.png'
+export const useHeaderFunds = () => {
+	const action = useAction()
+	const setBGImage = () => {
+		action.value.setBackgroundImage(bgImage)
+	}
+
+
+	const describes = new Map<Function, { name: string, icon: string }>([
+		[revoke, {name: '撤销', icon: ''}],
+		[recover, {name: '撤销', icon: ''}],
+		[rotate, {name: '旋转', icon: ''}],
+		[clear, {name: '清除', icon: ''}],
+		[full, {name: '全屏', icon: ''}],
+		[aiImport, {name: 'ai导入', icon: ''}],
+		[
+			setBGImage,
+			{name: '背景图', icon: ''}
+		],
+		[gotoVR, {name: 'VR', icon: ''}],
+		[saveData, {name: '保存', icon: ''}],
+		[exportData, {name: '导出', icon: ''}],
+		[gotoDrawing, {name: '图纸', icon: ''}],
+	])
+
+	return {
+		revoke,
+		recover,
+		rotate,
+		clear,
+		full,
+		aiImport,
+		setBGImage,
+		gotoVR,
+		saveData,
+		exportData,
+		gotoDrawing,
+		describes
+	}
+}

+ 59 - 0
src/views/header/header.vue

@@ -0,0 +1,59 @@
+<template>
+	<div class="header">
+		<div class="nav">
+			<el-button type="primary" plain>返回</el-button>
+		</div>
+		<div class="draw-operate">
+			<div v-for="group in groups">
+				<el-icon class="operate" v-for="fn in group" @click="fn()">
+					{{ funds.describes.get(fn)?.name }}
+				</el-icon>
+			</div>
+		</div>
+		<div class="saves">
+			<el-button type="primary" plain>保存</el-button>
+			<el-button type="primary" plain>导出</el-button>
+			<el-button>图纸</el-button>
+		</div>
+	</div>
+</template>
+
+<script lang="ts" setup>
+import { useHeaderFunds } from './funds.ts'
+const funds = useHeaderFunds()
+const groups = [
+	[funds.revoke, funds.recover],
+	[funds.clear, funds.rotate, funds.full],
+	[funds.aiImport, funds.setBGImage, funds.gotoVR],
+]
+</script>
+
+<style lang="scss" scoped>
+@use 'element-plus/theme-chalk/src/common/var';
+
+.header {
+	background-color: var.$color-primary;
+	display: flex;
+	align-items: center;
+	padding: 10px;
+	justify-content: space-between;
+}
+
+.draw-operate {
+	text-align: center;
+	color: #fff;
+	display: flex;
+	align-items: center;
+
+	> div:not(:last-child) {
+		padding-right: 10px;
+		margin-right: 10px;
+		border-right: 1px solid #fff;
+	}
+
+	i {
+		width: auto;
+		margin: 0 5px;
+	}
+}
+</style>/

+ 1 - 0
src/views/header/index.ts

@@ -0,0 +1 @@
+export * from './funds.ts'

+ 61 - 0
src/views/home.vue

@@ -0,0 +1,61 @@
+<template>
+	<div class="layout">
+		<Header class="header" />
+		<div class="container">
+			<Slide class="slide" />
+			<div class="content" ref="drawEle">
+				<Renderer v-if="drawEle"  ref="draw" />
+			</div>
+		</div>
+	</div>
+</template>
+
+<script lang="ts" setup>
+import Header from "@/views/header/header.vue";
+import { ref } from 'vue'
+import { Slide } from "./slide";
+import { DrawExpose, Renderer } from "@/draw";
+import { installAction } from "@/views/use-draw.ts";
+
+const drawEle = ref<HTMLDivElement | null>(null);
+const draw = ref<DrawExpose>();
+installAction(draw)
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/global';
+
+.layout {
+	display: flex;
+	flex-direction: column;
+	align-items: stretch;
+	height: 100vh;
+	background: #f0f2f5;
+
+	.header {
+		height: global.$headerSize;
+	}
+
+	.container {
+		position: relative;
+		width: 100%;
+		height: calc(100% - #{global.$headerSize});
+	}
+
+	.slide {
+		position: absolute;
+		left: 0;
+		width: global.$slideSize;
+		overflow-y: auto;
+		height: 100%;
+		top: 0;
+		background: #fff;
+		z-index: 1;
+	}
+
+	.content {
+		position: absolute;
+		inset: 0;
+	}
+}
+</style>

+ 4 - 0
src/views/slide/index.ts

@@ -0,0 +1,4 @@
+export { default as Slide } from './slide.vue';
+export { default as SlideItem } from './slide-item.vue';
+export { menus } from './menu.ts'
+export type { MenuItem } from './menu.ts'

+ 101 - 0
src/views/slide/menu.ts

@@ -0,0 +1,101 @@
+import { ShapeType, shapeNames, PresetAdd } from '@/draw'
+import { v4 as uuid } from 'uuid'
+
+
+export type MenuItem = {
+	icon: string,
+	name: string,
+	value?: string,
+	children?: MenuItem[]
+	payload?: PresetAdd
+}
+
+const genItem = <T extends ShapeType>(type: T, preset: PresetAdd<T>['preset'] = {}) => ({
+	name: shapeNames[type],
+	value: uuid(),
+	payload: {type, preset}
+})
+
+export const getItem = (value: string, queryMenus = menus): MenuItem | undefined => {
+	for (const menu of queryMenus) {
+		const eqItem = menu.value === value
+			? menu
+			: menu.children?.length ? getItem(value, menu.children) : void 0
+		if (eqItem) return eqItem
+	}
+}
+
+const eqPayload = (p1?: PresetAdd, p2?: PresetAdd) => {
+	if (!p2 || !p1 || p1.type !== p2.type) return false;
+	return !p1.preset || !p2.preset ||  toRaw(p1.preset) === toRaw(p2.preset)
+}
+
+export const getValue = (payload: PresetAdd, queryMenus = menus): string | undefined => {
+	for (const menu of queryMenus) {
+		const eqItem = eqPayload(menu.payload, payload)
+			? menu.value
+			: menu.children?.length ? getValue(payload, menu.children) : void 0
+		if (eqItem) return eqItem
+	}
+}
+
+
+import svg1 from '@/assets/icons/vue.svg'
+import svg2 from '@/assets/icons/BedsideCupboard.svg'
+import { toRaw } from "vue";
+
+export const menus: MenuItem[] = [
+	{
+		icon: '',
+		name: '绘制',
+		value: uuid(),
+		children: [
+			{
+				icon: '',
+				...genItem('line')
+			},
+			{
+				icon: '',
+				...genItem('arrow')
+			},
+
+			{
+				icon: '',
+				...genItem('rectangle')
+			},
+			{
+				icon: '',
+				...genItem('circle')
+			},
+			{
+				icon: '',
+				...genItem('triangle')
+			},
+			{
+				icon: '',
+				...genItem('polygon')
+			},
+		]
+	},
+	{
+		icon: '',
+		name: '图例',
+		value: uuid(),
+		children: [
+			{
+				icon: '',
+				...genItem('icon', { url: svg1 }),
+				name: 'vue'
+			},
+			{
+				icon: '',
+				...genItem('icon', { url: svg2, stroke: 'red', strokeWidth: 1, strokeScaleEnabled: false }),
+				name: '自定义'
+			}
+		]
+	},
+	{
+		icon: '',
+		...genItem('text')
+	},
+]

+ 22 - 0
src/views/slide/slide-item.vue

@@ -0,0 +1,22 @@
+<template>
+	<el-sub-menu :index="data.value" v-if="data.children?.length">
+		<template #title>
+			<p class="center">{{ data.name }}</p>
+		</template>
+		<SlideItem v-for="item in data.children" :data="item" />
+	</el-sub-menu>
+	<el-menu-item v-else :index="data.value">
+		<p class="center">{{data.name}}</p>
+	</el-menu-item>
+</template>
+
+<script lang="ts" setup>
+import { MenuItem } from "@/views/slide/menu.ts";
+defineProps<{ data: MenuItem }>()
+</script>
+
+<style scoped lang="scss">
+.center {
+	width: 100%;
+}
+</style>

+ 53 - 0
src/views/slide/slide.vue

@@ -0,0 +1,53 @@
+<template>
+	<div class="slide">
+		<el-menu
+				:default-active="action.presetAdd && getValue(action.presetAdd)"
+				class="slide-menu"
+				@select="selectHandler"
+				collapse
+				:popper-offset="0"
+				popper-class="slide-popper">
+			<SlideItem v-for="menu in menus" :data="menu"/>
+		</el-menu>
+	</div>
+</template>
+
+<script lang="ts" setup>
+import { getItem, getValue, menus } from './menu.ts'
+import SlideItem from "@/views/slide/slide-item.vue";
+import { useAction } from "@/views/use-draw.ts";
+
+const action  = useAction()
+const selectHandler = (value: string) => {
+	const item = getItem(value)
+	if (!item || !item.payload) throw '无效菜单'
+	action.value.enterMouseAddShape(item.payload.type, item.payload.preset)
+}
+</script>/
+
+<style lang="scss" scoped>
+@use '@/styles/global';
+
+.slide {
+	transition: transform .3s ease;
+	margin-left: 0;
+
+	&.hide {
+		transform: translateX(-100%);
+	}
+}
+
+.slide-menu {
+	width: 100%;
+	height: 100%;
+	overflow-y: auto;
+}
+</style>
+
+<style lang="scss">
+@use '@/styles/global';
+
+.slide-popper .el-menu--popup {
+	min-width: global.$slideSize;
+}
+</style>/

+ 26 - 0
src/views/use-draw.ts

@@ -0,0 +1,26 @@
+import { inject, provide, Ref, ref, watchEffect } from 'vue'
+import { DrawExpose } from "@/draw";
+
+const attachAction = (draw: DrawExpose) => ({
+	setBackgroundImage(url: string) {
+		return draw.addShape('image', { url, width: 1, height: 1 }, {x: window.innerWidth / 2, y: window.innerHeight / 2}, true );
+	}
+})
+
+const actionKey = Symbol('drawAction');
+export const installAction = (drawRef: Ref<DrawExpose | undefined>) => {
+	const actions = ref({} as DrawExpose & ReturnType<typeof attachAction>)
+
+	watchEffect(() => {
+		if (drawRef.value) {
+			actions.value = drawRef.value as any
+			Object.assign(actions.value, attachAction(drawRef.value))
+		}
+	})
+	provide(actionKey, actions)
+	return actions
+}
+
+export const useAction = () => {
+	return inject<ReturnType<typeof installAction>>(actionKey)!
+}

+ 1 - 0
src/vite-env.d.ts

@@ -0,0 +1 @@
+/// <reference types="vite/client" />

+ 27 - 0
tsconfig.app.json

@@ -0,0 +1,27 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "Bundler",
+    "allowImportingTsExtensions": true,
+    "isolatedModules": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+    "jsx": "preserve",
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedSideEffectImports": true,
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  },
+  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+}

+ 7 - 0
tsconfig.json

@@ -0,0 +1,7 @@
+{
+  "files": [],
+  "references": [
+    { "path": "./tsconfig.app.json" },
+    { "path": "./tsconfig.node.json" }
+  ]
+}

+ 23 - 0
tsconfig.node.json

@@ -0,0 +1,23 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "lib": ["ES2023"],
+    "module": "ESNext",
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "Bundler",
+    "allowImportingTsExtensions": true,
+    "isolatedModules": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedSideEffectImports": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 36 - 0
vite.config.ts

@@ -0,0 +1,36 @@
+import { defineConfig } from 'vite'
+import AutoImport from 'unplugin-auto-import/vite'
+import Components from 'unplugin-vue-components/vite'
+import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
+import vue from '@vitejs/plugin-vue'
+import path from 'node:path'
+
+// https://vite.dev/config/
+export default defineConfig({
+	resolve: {
+		alias: {
+			'@/': `${path.resolve(__dirname, 'src')}/`,
+		},
+	},
+  css: {
+    preprocessorOptions: {
+      scss: {
+				additionalData: `@use "@/styles/element.scss" as *;`,
+      },
+    },
+  },
+	server: {
+		port: 9000,
+		open: true,
+		host: '0.0.0.0',
+	},
+	plugins: [
+		vue(),
+		// AutoImport({
+		// 	resolvers: [ElementPlusResolver({importStyle: 'sass'})],
+		// }),
+		Components({
+			resolvers: [ElementPlusResolver({importStyle: 'sass'})],
+		})
+	],
+})