瀏覽代碼

feat: 制作图纸页面

bill 3 月之前
父節點
當前提交
cd664aaf5a

+ 1 - 0
package.json

@@ -16,6 +16,7 @@
     "@types/three": "^0.169.0",
     "element-plus": "^2.8.6",
     "html2canvas": "^1.4.1",
+    "jspdf": "^3.0.1",
     "konva": "^9.3.18",
     "localforage": "^1.10.0",
     "mitt": "^3.0.1",

+ 109 - 0
pnpm-lock.yaml

@@ -9,6 +9,7 @@ specifiers:
   '@vitejs/plugin-vue': ^5.1.4
   element-plus: ^2.8.6
   html2canvas: ^1.4.1
+  jspdf: ^3.0.1
   konva: ^9.3.18
   localforage: ^1.10.0
   mitt: ^3.0.1
@@ -35,6 +36,7 @@ dependencies:
   '@types/three': 0.169.0
   element-plus: 2.8.6_vue@3.5.13
   html2canvas: 1.4.1
+  jspdf: 3.0.1
   konva: 9.3.18
   localforage: 1.10.0
   mitt: 3.0.1
@@ -77,6 +79,13 @@ packages:
     dependencies:
       '@babel/types': 7.26.0
 
+  /@babel/runtime/7.27.0:
+    resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      regenerator-runtime: 0.14.1
+    dev: false
+
   /@babel/types/7.26.0:
     resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==}
     engines: {node: '>=6.9.0'}
@@ -645,6 +654,11 @@ packages:
     dependencies:
       undici-types: 6.19.8
 
+  /@types/raf/3.4.3:
+    resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
+    dev: false
+    optional: true
+
   /@types/stats.js/0.17.3:
     resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
     dev: false
@@ -666,6 +680,12 @@ packages:
       meshoptimizer: 0.18.1
     dev: false
 
+  /@types/trusted-types/2.0.7:
+    resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+    requiresBuild: true
+    dev: false
+    optional: true
+
   /@types/web-bluetooth/0.0.16:
     resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
     dev: false
@@ -995,6 +1015,12 @@ packages:
     dependencies:
       fill-range: 7.1.1
 
+  /btoa/1.2.1:
+    resolution: {integrity: sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==}
+    engines: {node: '>= 0.4.0'}
+    hasBin: true
+    dev: false
+
   /buffer-builder/0.2.0:
     resolution: {integrity: sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==}
 
@@ -1050,6 +1076,22 @@ packages:
       tslib: 2.8.0
     dev: false
 
+  /canvg/3.0.11:
+    resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
+    engines: {node: '>=10.0.0'}
+    requiresBuild: true
+    dependencies:
+      '@babel/runtime': 7.27.0
+      '@types/raf': 3.4.3
+      core-js: 3.41.0
+      raf: 3.4.1
+      regenerator-runtime: 0.13.11
+      rgbcolor: 1.0.1
+      stackblur-canvas: 2.7.0
+      svg-pathdata: 6.0.3
+    dev: false
+    optional: true
+
   /chalk/1.1.3:
     resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==}
     engines: {node: '>=0.10.0'}
@@ -1159,6 +1201,12 @@ packages:
     engines: {node: '>=0.10.0'}
     dev: false
 
+  /core-js/3.41.0:
+    resolution: {integrity: sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==}
+    requiresBuild: true
+    dev: false
+    optional: true
+
   /cors/2.8.5:
     resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
     engines: {node: '>= 0.10'}
@@ -1350,6 +1398,14 @@ packages:
       domelementtype: 2.3.0
     dev: false
 
+  /dompurify/3.2.5:
+    resolution: {integrity: sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==}
+    requiresBuild: true
+    optionalDependencies:
+      '@types/trusted-types': 2.0.7
+    dev: false
+    optional: true
+
   /domutils/1.7.0:
     resolution: {integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==}
     dependencies:
@@ -2221,6 +2277,20 @@ packages:
       graceful-fs: 4.2.11
     dev: false
 
+  /jspdf/3.0.1:
+    resolution: {integrity: sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==}
+    dependencies:
+      '@babel/runtime': 7.27.0
+      atob: 2.1.2
+      btoa: 1.2.1
+      fflate: 0.8.2
+    optionalDependencies:
+      canvg: 3.0.11
+      core-js: 3.41.0
+      dompurify: 3.2.5
+      html2canvas: 1.4.1
+    dev: false
+
   /kind-of/3.2.2:
     resolution: {integrity: sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==}
     engines: {node: '>=0.10.0'}
@@ -2555,6 +2625,11 @@ packages:
     resolution: {integrity: sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw==}
     dev: false
 
+  /performance-now/2.1.0:
+    resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
+    dev: false
+    optional: true
+
   /picocolors/1.1.1:
     resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
 
@@ -2671,6 +2746,13 @@ packages:
     resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
     dev: false
 
+  /raf/3.4.1:
+    resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
+    dependencies:
+      performance-now: 2.1.0
+    dev: false
+    optional: true
+
   /readable-stream/3.6.2:
     resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
     engines: {node: '>= 6'}
@@ -2698,6 +2780,15 @@ packages:
       which-builtin-type: 1.2.1
     dev: false
 
+  /regenerator-runtime/0.13.11:
+    resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
+    dev: false
+    optional: true
+
+  /regenerator-runtime/0.14.1:
+    resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
+    dev: false
+
   /regex-not/1.0.2:
     resolution: {integrity: sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==}
     engines: {node: '>=0.10.0'}
@@ -2748,6 +2839,12 @@ packages:
     engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
     dev: false
 
+  /rgbcolor/1.0.1:
+    resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
+    engines: {node: '>= 0.8.15'}
+    dev: false
+    optional: true
+
   /rollup/4.24.2:
     resolution: {integrity: sha512-do/DFGq5g6rdDhdpPq5qb2ecoczeK6y+2UAjdJ5trjQJj5f1AiVdLRWRc9A9/fFukfvJRgM0UXzxBIYMovm5ww==}
     engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -3196,6 +3293,12 @@ packages:
     deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility'
     dev: false
 
+  /stackblur-canvas/2.7.0:
+    resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
+    engines: {node: '>=0.1.14'}
+    dev: false
+    optional: true
+
   /stateshot/1.3.5:
     resolution: {integrity: sha512-A/I230vCzTBDHAc2wzCXrH3ofcNnMd9Cs/HhRrxjWJ1YI90cOklljX9XATTdU45T4W/c/+g+jBtS/oQLs+Wkdw==}
     dev: false
@@ -3303,6 +3406,12 @@ packages:
       - supports-color
     dev: false
 
+  /svg-pathdata/6.0.3:
+    resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
+    engines: {node: '>=12.0.0'}
+    dev: false
+    optional: true
+
   /svgo/2.8.0:
     resolution: {integrity: sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==}
     engines: {node: '>=10.13.0'}

+ 4 - 4
src/core/components/image/index.ts

@@ -27,10 +27,10 @@ export const getMouseStyle = (data: ImageData) => {
 
   return {
     default: { stroke: data.stroke || defaultStyle.stroke },
-    hover: { stroke: strokeStatus.hover },
-    focus: { stroke: strokeStatus.hover },
-    select: { stroke: strokeStatus.select },
-    press: { stroke: strokeStatus.press },
+    hover: { stroke: strokeStatus.hover, strokeWidth: 1 },
+    focus: { stroke: strokeStatus.hover, strokeWidth: 1 },
+    select: { stroke: strokeStatus.select, strokeWidth: 1 },
+    press: { stroke: strokeStatus.press, strokeWidth: 1 },
   };
 };
 

+ 19 - 21
src/core/helper/compass.vue

@@ -26,21 +26,22 @@ import {
   useViewer,
   useViewerTransformConfig,
 } from "../hook/use-viewer.ts";
+import { useStore } from "../store/index.ts";
 
 const config = useConfig();
+const store = useStore();
+
 const data = ref({
-  // stroke: "#000000",
-  // fill: themeColor,
   coverOpcatiy: 0,
   strokeScaleEnabled: false,
   width: 80,
   height: 80,
   rotation: 0,
-  url: config.compass?.url || "icons/edit_compass.svg",
+  url: store.config.compass.url,
 });
 
 watch(
-  () => config.compass!.url,
+  () => store.config.compass.url,
   (url) => {
     data.value.url = url || "icons/edit_compass.svg";
   }
@@ -59,28 +60,24 @@ const [style] = useAnimationMouseStyle({
   },
 } as any);
 
-const other = ref({ rotate: config.compass!.rotation });
-watch(
-  () => config.compass!.rotation,
-  (val) => {
-    other.value.rotate = val;
-  }
-);
-const changeHandler = () => {
-  config.compass!.rotation = other.value.rotate;
-};
-
-const describes = mergeDescribes(other, {}, ["rotate"]);
+let currentRotation = store.config.compass.rotation;
+const describes = mergeDescribes(data, {}, ["rotate"]);
 describes.rotate = {
   ...describes.rotate,
   sort: 3,
   get value() {
-    return other.value.rotate;
+    return store.config.compass.rotation;
   },
   set value(val) {
-    other.value.rotate = val;
+    store.config.compass.rotation = val;
   },
 };
+const changeHandler = () => {
+  if (currentRotation !== store.config.compass.rotation) {
+    store.setConfig({});
+    currentRotation = store.config.compass.rotation;
+  }
+};
 
 const getPixel = useGetViewBoxPositionPixel();
 const viewerConfig = useViewerTransformConfig();
@@ -98,12 +95,13 @@ const mat = computed(() => {
     { right: 20 + margin.value[1], top: 20 + margin.value[0] },
     data.value
   );
-  tf.translate(pos.x, pos.y);
+  tf.translate(pos.x + data.value.width / 2, pos.y + data.value.height / 2);
   if (sizeMat.value) {
     tf.scale(viewerConfig.value.scaleX, viewerConfig.value.scaleY);
   }
-  tf.rotate(MathUtils.degToRad(other.value.rotate + viewerConfig.value.rotation));
-  tf.translate(data.value.width / 2, data.value.height / 2);
+  tf.rotate(
+    MathUtils.degToRad(store.config.compass.rotation + viewerConfig.value.rotation)
+  );
   return tf.m;
 });
 </script>

+ 2 - 32
src/core/hook/use-config.ts

@@ -1,9 +1,7 @@
-import { computed, nextTick, reactive } from "vue";
+import { computed, reactive } from "vue";
 import { installGlobalVar } from "./use-global-vars";
 import { useGlobalResize } from "./use-event";
 import { Size } from "@/utils/math";
-import { useStore } from "../store";
-import { StoreConfig } from "../store/store";
 
 export type Border = {
   color?: string;
@@ -33,41 +31,13 @@ export const defConfig: Config = {
 
 export const useConfig = installGlobalVar(() => {
   const { setFixSize, size } = useGlobalResize();
-  const store = useStore();
-
-  let repConfig = { ...store.config };
-  let isWait = false;
-  const setConfig = <T extends keyof StoreConfig>(
-    key: T,
-    val: StoreConfig[T]
-  ) => {
-    repConfig[key] = val;
-    if (isWait) return;
-    isWait = true
-    nextTick(() => {
-      store.setConfig(repConfig);
-      isWait = false;
-      repConfig = { ...store.config };
-    });
-  };
-
-  const fast = <T extends keyof StoreConfig>(key: T) =>
-    computed({
-      get: () => store.config[key],
-      set: (val: StoreConfig[T]) => {
-        setConfig(key, val);
-      },
-    });
-
-    
   return reactive({
     ...defConfig,
-    compass: fast('compass'),
     size: computed({
       get: () => size.value!,
       set: (size: Size | null) => {
         setFixSize(size)
       },
     }),
-  }) as Config & StoreConfig;
+  }) as Config ;
 });

+ 6 - 18
src/core/hook/use-event.ts

@@ -3,6 +3,7 @@ import { listener } from "../../utils/event.ts";
 import { installGlobalVar, useStage } from "./use-global-vars.ts";
 import { nextTick, ref, watchEffect } from "vue";
 import { KonvaEventObject } from "konva/lib/Node";
+import { debounce } from "@/utils/shared.ts";
 
 export const useListener = <
   T extends HTMLElement,
@@ -55,31 +56,21 @@ export const useGlobalResize = installGlobalVar(() => {
       nextTick(() => stopWatch());
     }
   });
-  let unResize = listener(window, 'resize', setSize)
   const fix = ref(false);
-  let unWatch: (() => void) | null = null;
-
   const setFixSize = (fixSize: { width: number; height: number } | null) => {
     if (fixSize) {
       size.value = { ...fixSize };
-      unWatch && unWatch();
-      unWatch = watchEffect(() => {
+      const unWatch = watchEffect(() => {
         const $stage = stage.value?.getStage();
         if ($stage) {
           $stage.width(fixSize.width);
           $stage.height(fixSize.height);
-          nextTick(() => unWatch && unWatch());
+          nextTick(() => unWatch());
         }
       });
     }
-    if (fix.value && !fixSize) {
-      unResize = listener(window, 'resize', setSize)
-      fix.value = false;
-      nextTick(setSize)
-    } else if (!fix.value && fixSize) {
-      fix.value = true;
-      unResize();
-    }
+    fix.value = !!fixSize
+    setSize()
   };
 
   return {
@@ -89,10 +80,7 @@ export const useGlobalResize = installGlobalVar(() => {
       size,
       fix,
     },
-    onDestroy: () => {
-      fix || unResize();
-      unWatch && unWatch();
-    },
+    onDestroy: listener(window, 'resize', debounce(setSize, 16))
   };
 }, Symbol("resize"));
 

+ 4 - 2
src/core/hook/use-expose.ts

@@ -9,7 +9,7 @@ import { useMode, useOperMode } from "./use-status";
 import { Stage } from "konva/lib/Stage";
 import { useInteractiveProps } from "./use-interactive.ts";
 import { useStore } from "../store/index.ts";
-import { useViewer } from "./use-viewer.ts";
+import { useGetViewBoxPositionPixel, useViewer } from "./use-viewer.ts";
 import { useGlobalResize, useListener } from "./use-event.ts";
 import { useInteractiveDrawShapeAPI } from "./use-draw.ts";
 import { useHistory } from "./use-history.ts";
@@ -205,7 +205,7 @@ export const useExpose = installGlobalVar(() => {
   const store = useStore();
   const history = useHistory();
   const viewer = useViewer().viewer;
-  const { updateSize } = useGlobalResize();
+  const { updateSize, setFixSize } = useGlobalResize();
   const exposeBlob = (config?: PickParams<"toBlob", "callback">) => {
     const $stage = stage.value!.getStage();
     return new Promise<Blob>((resolve) => {
@@ -229,6 +229,7 @@ export const useExpose = installGlobalVar(() => {
       x: rect.x + rect.width,
       y: rect.y + rect.height,
     });
+    console.log()
     viewer.setBound({
       targetBound: {
         ...lt,
@@ -256,6 +257,7 @@ export const useExpose = installGlobalVar(() => {
     getData() {
       return store.data;
     },
+    getViewBoxPositionPixel: useGetViewBoxPositionPixel(),
     viewer,
     presetAdd: interactiveProps,
     config: useConfig(),

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

@@ -1,5 +1,5 @@
 import { Viewer } from "../viewer.ts";
-import { computed, Ref, ref, watch, watchEffect } from "vue";
+import { computed, ref, watch, watchEffect } from "vue";
 import { dragListener, scaleListener } from "../../utils/event.ts";
 import { globalWatch, installGlobalVar, useStage } from "./use-global-vars.ts";
 import { useCan } from "./use-status";
@@ -16,6 +16,7 @@ export const useViewer = installGlobalVar(() => {
   const size = useResize();
   const transform = ref(new Transform());
   const sizeMat = ref<Transform | null>(null);
+  
 
   const init = (dom: HTMLDivElement) => {
     const onDestroy = mergeFuns(

+ 1 - 1
src/core/store/index.ts

@@ -80,7 +80,7 @@ export const useStore = installGlobalVar(() => {
     isRuning = true;
     store.bus.emit((name + "Before") as any, args);
     nextTick(() => store.bus.emit((name + "After") as any, args))
-
+    
     if (currentIsRuning) {
       return;
     } else if (!trackActions.includes(name)) {

+ 8 - 5
src/core/store/store.ts

@@ -11,7 +11,7 @@ export type StoreConfig = {
   proportion: { scale: number, unit: string }
   compass: {
     rotation: number;
-    url?: string;
+    url: string;
   };
 };
 export type StoreData = {
@@ -20,7 +20,7 @@ export type StoreData = {
   __currentLayer: string;
 };
 const defConfig: StoreData["config"] = {
-  compass: { rotation: 0 },
+  compass: { rotation: 0, url: 'icons/edit_compass.svg' },
   proportion: {scale: 1, unit: ''}
 };
 
@@ -67,12 +67,15 @@ export const useStoreRaw = defineStore("draw-data", {
     setStore(store: Partial<StoreData>) {
       const newStore = JSON.parse(JSON.stringify(store)) ;
       this.$patch((state) => {
-        console.log(state.data)
         state.data = {
           ...state.data,
-          ...newStore
+          ...newStore,
+          config: {
+            ...state.data.config,
+            ...(newStore.config || {})
+          }
         };
-        console.log(state.data)
+        console.error(state.data)
       });
     },
     setLayerStore(layerStore: DrawData) {

+ 2 - 4
src/core/viewer.ts

@@ -22,12 +22,12 @@ export class Viewer {
     this.updateSizeMat()
   }
 
-  setViewSize(size: Size) {
+  setViewSize(size?: Size) {
     this.viewSize = size
     this.updateSizeMat()
   }
 
-  private updateSizeMat() {
+  updateSizeMat() {
     if (!this.size || !this.viewSize) {
       this.sizeMat = null
     } else {
@@ -38,7 +38,6 @@ export class Viewer {
 
     this.bus.emit("transformChange", this.transform);
     this.bus.emit('viewSizeChange')
-    console.log(this.transform.decompose())
   }
 
   setBound({
@@ -149,7 +148,6 @@ export class Viewer {
     } else {
       this.viewMat = new Transform(mat);
     }
-
     this.bus.emit("transformChange", this.transform);
   }
 

+ 1 - 1
src/example/components/container/container.vue

@@ -76,7 +76,7 @@ defineExpose({
   overflow: hidden;
 
   .container {
-    flex: 1;
+    height: calc(100vh - #{global.$headerSize});
     display: flex;
     align-items: stretch;
   }

+ 3 - 1
src/example/components/header/actions.ts

@@ -2,9 +2,10 @@ import { computed, nextTick, reactive } from "vue";
 import { Draw } from "../container/use-draw";
 import { animation } from "@/core/hook/use-animation";
 import saveAs from "@/utils/file-serve";
+import { ElMessage } from "element-plus";
 
 export type Action = {
-  handler?: () => void;
+  handler?: (draw: Draw) => void;
   text?: string;
   icon: string;
   disabled?: boolean;
@@ -85,6 +86,7 @@ export const getHeaderActions = (draw: Draw) => {
             await nextTick()
             await saveAs(await getImage(draw, 'image/png'), "canvas.png");
             draw.config.back = oldBack
+            ElMessage.success("导出成功");
           },
           text: "png",
           icon: "a-visible",

+ 8 - 2
src/example/components/header/index.vue

@@ -15,7 +15,7 @@
           <span
             v-if="!action.children"
             class="operate"
-            @click="action.handler"
+            @click="action.handler && action.handler(draw)"
             :class="{ disabled: action.disabled }"
           >
             <Icon :name="action.icon" :tip="action.text" />
@@ -49,8 +49,14 @@
 import { router } from "@/example/fuse/router";
 import { ActionGroups } from "./actions";
 import { ElDropdown, ElDropdownItem, ElDropdownMenu } from "element-plus";
+import { Draw } from "../container/use-draw";
 
-defineProps<{ actionGroups: ActionGroups; title?: string; noBack?: boolean }>();
+defineProps<{
+  actionGroups: ActionGroups;
+  draw: Draw;
+  title?: string;
+  noBack?: boolean;
+}>();
 </script>
 
 <style lang="scss" scoped>

+ 27 - 16
src/example/components/slide/actions.ts

@@ -91,14 +91,21 @@ export const imp: MenuItem = {
       }
     }
   ]
-
 }
 
-const setPaper = (draw: Draw, p: number[], scale: number) => {
+export const getPaperConfig = (p: number[], scale: number) => {
   const pad = 5 * scale;
   const size = { width: p[0] * scale, height: p[1] * scale };
   const margin = [pad, pad, pad, pad * 5];
+  return {size, margin}
+}
+export const paperConfigs = {
+  'a4': { size: [297, 210], scale: 3.8 },
+  'a3': { size: [450, 297], scale: 2.5 }
+}
 
+const setPaper = (draw: Draw, p: number[], scale: number) => {
+  const { size, margin } = getPaperConfig(p, scale)
   // draw.config.size = size;
   draw.viewer.setViewSize(size)
   draw.config.back = { color: "#fff", opacity: 1 };
@@ -117,29 +124,33 @@ export const paper = {
   type: 'sub-menu-horizontal',
   value: uuid(),
   children: [
-    {
-      value: uuid(),
-      icon: "A4_v",
-      name: "A4竖版",
-      handler: (draw: Draw) => setPaper(draw, [210, 297], 2.8),
-    },
+    // {
+    //   value: uuid(),
+    //   icon: "A4_v",
+    //   key: 'a4',
+    //   name: "A4竖版",
+    //   handler: (draw: Draw) => setPaper(draw, [210, 297], 2.8),
+    // },
     {
       value: uuid(),
       icon: "A4_h",
+      key: 'a4',
       name: "A4横版",
-      handler: (draw: Draw) => setPaper(draw, [297, 210], 3.8),
-    },
-    {
-      value: uuid(),
-      icon: "A3_v",
-      name: "A3竖版",
-      handler: (draw: Draw) => setPaper(draw, [297, 450], 1.8),
+      handler: (draw: Draw) => setPaper(draw, paperConfigs.a4.size, paperConfigs.a4.scale),
     },
+    // {
+    //   value: uuid(),
+    //   icon: "A3_v",
+    //   key: 'a3',
+    //   name: "A3竖版",
+    //   handler: (draw: Draw) => setPaper(draw, [297, 450], 1.8),
+    // },
     {
       value: uuid(),
       icon: "A3_h",
+      key: 'a3',
       name: "A3横版",
-      handler: (draw: Draw) => setPaper(draw, [450, 297], 2.5),
+      handler: (draw: Draw) => setPaper(draw, paperConfigs.a3.size, paperConfigs.a3.scale),
     },
   ],
 };

+ 2 - 1
src/example/dialog/dialog.vue

@@ -29,9 +29,10 @@ import { reactive, ref, shallowRef, watch, watchEffect } from "vue";
 import { props as baseMapProps } from "./basemap";
 import { props as VRProps } from "./vr";
 import { props as AIProps } from "./ai";
+import { props as EFProps } from "./expose";
 import { mergeFuns } from "@/utils/shared";
 
-const dialogsProps = reactive([baseMapProps, VRProps, AIProps]);
+const dialogsProps = reactive([baseMapProps, VRProps, AIProps, EFProps]);
 const contentRefs = ref<any[]>([]);
 const showContents = ref<boolean[]>([]);
 const submits = shallowRef<(() => any)[]>([]);

+ 59 - 0
src/example/dialog/expose/expose-format.vue

@@ -0,0 +1,59 @@
+<template>
+  <div style="padding: 20px 20px 40px">
+    <el-form
+      :model="data"
+      label-width="70px"
+      style="max-width: 600px"
+      label-position="left"
+    >
+      <el-form-item label="格式:">
+        <el-radio-group v-model="data.format">
+          <el-radio value="JPEG">JPEG</el-radio>
+          <el-radio value="PDF">PDF</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="颜色:">
+        <el-radio-group v-model="data.color">
+          <el-radio value="grayscale">黑白</el-radio>
+          <el-radio value="raw">彩色</el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from "vue";
+import { ExposeFormatData } from ".";
+import { ElForm, ElFormItem, ElRadioGroup, ElRadio } from "element-plus";
+
+const props = defineProps<{ ef?: ExposeFormatData }>();
+const data = ref<ExposeFormatData>(
+  props.ef
+    ? { ...props.ef }
+    : {
+        color: "raw",
+        format: "JPEG",
+      }
+);
+
+defineExpose({
+  submit: async (): Promise<ExposeFormatData> => {
+    return data.value;
+  },
+});
+</script>
+
+<style lang="scss" scoped>
+.vr-layout {
+  padding: 0 !important;
+}
+.tagging-layout {
+  margin-top: 24px;
+}
+.title {
+  font-size: 14px;
+  color: rgba(0, 0, 0, 0.85);
+  margin: 0;
+}
+</style>

+ 36 - 0
src/example/dialog/expose/index.ts

@@ -0,0 +1,36 @@
+import { markRaw, reactive } from "vue";
+import ExposeFormat from "./expose-format.vue";
+
+type Props = {
+  title: string;
+  width: string;
+  visiable: boolean;
+  args: {ef?: ExposeFormatData},
+  height?: string;
+  content: any;
+  submit: (info: ExposeFormatData) => void;
+  cancel: () => void;
+};
+
+export type ExposeFormatData = {format: string, color: 'raw' | 'grayscale' }
+
+export const props = reactive({
+  title: "导出设置",
+  width: "500px",
+}) as Props;
+
+export const selectExposeFormat = (args?: ExposeFormatData) =>
+  new Promise<ExposeFormatData>((resolve, reject) => {
+    props.content = markRaw(ExposeFormat);
+    props.title = "导出设置";
+    props.visiable = true;
+    props.args = {ef: args}
+    props.submit = (info) => {
+      resolve(info);
+      props.visiable = false
+    };
+    props.cancel = () => {
+      reject("cancel");
+      props.visiable = false
+    };
+  });

+ 6 - 1
src/example/fuse/req.ts

@@ -25,16 +25,21 @@ export const getTabulationData = async () => {
   const vportStr = localStorage.getItem("tab-view-port");
   const vport = (vportStr ? JSON.parse(vportStr) : null) as number[] | null;
 
+  const paperKeyStr = localStorage.getItem("tab-paper-key");
+  const paperKey = paperKeyStr ? JSON.parse(paperKeyStr) : 'a4'
+
   return {
     store,
     cover: tabCoverUrl,
+    paperKey,
     viewport: vport
   }
 }
 
-export const saveTabulationData = async (data: {store: StoreData, viewport: number[] | null}) => {
+export const saveTabulationData = async (data: {store: StoreData, viewport: number[] | null, paperKey?: string}) => {
   localStorage.setItem("tab-draw-data", JSON.stringify(data.store));
   localStorage.setItem("tab-view-port", JSON.stringify(data.viewport));
+  localStorage.setItem("tab-paper-key", JSON.stringify(data.paperKey));
 }
 
 let tabCoverUrl: string | null = null

+ 1 - 1
src/example/fuse/router.ts

@@ -19,4 +19,4 @@ setTimeout(() => {
       router.replace({ name: "overview" });
     }
   });
-}, 1000);
+}, 3000);

+ 27 - 0
src/example/fuse/store.ts

@@ -0,0 +1,27 @@
+import { Ref, ref } from "vue";
+import { getOverviewData, getTabulationData } from "./req";
+import { StoreData } from "@/core/store/store";
+
+export const tableCoverScale = 540 / 425
+export const tableCoverKey = '__tableCoverKey'
+export const tableTableKey = '__tableTableKey'
+export const tableTitleKey = '__tableTitleKey'
+export const tableCoverWidth = 514
+
+export const overviewData = ref() as Ref<{
+  store: StoreData;
+  viewport: number[] | null;
+}>
+export const refreshOverviewData = () => {
+  return getOverviewData().then(data => overviewData.value = data)
+}
+
+export const tabulationData = ref() as Ref<{
+  store: StoreData;
+  cover: string | null;
+  paperKey: string;
+  viewport: number[] | null;
+}>
+export const refreshTabulationData = () => {
+  return getTabulationData().then(data => tabulationData.value = data)
+}

+ 57 - 4
src/example/fuse/views/overview/header.vue

@@ -1,5 +1,5 @@
 <template>
-  <Header :action-groups="actions" title="绘图" no-back>
+  <Header :action-groups="actions" title="绘图" no-back :draw="draw">
     <template #saves>
       <el-button type="primary" @click="saveHandler">保存</el-button>
       <el-button @click="gotoTabulation" color="#E6E6E6">图纸</el-button>
@@ -14,9 +14,18 @@ import { useDraw } from "../../../components/container/use-draw.ts";
 import { selectScene } from "../../../dialog/vr/index.ts";
 import { Scene } from "../../../platform/platform-resource.ts";
 import { getHeaderActions, getImage } from "../../../components/header/actions.ts";
-import { saveOverviewData, saveTabulationCover, uploadResourse } from "../../req.ts";
+import {
+  saveOverviewData,
+  saveTabulationCover,
+  saveTabulationData,
+  uploadResourse,
+} from "../../req.ts";
 import { router } from "../../router.ts";
 import { params } from "@/example/env.ts";
+import { tabulationData, tableCoverScale, refreshTabulationData } from "../../store.ts";
+import { nextTick } from "vue";
+import { getRepTabulationStore } from "../tabulation/gen.ts";
+import { getPaperConfig, paperConfigs } from "@/example/components/slide/actions.ts";
 
 const draw = useDraw();
 
@@ -46,11 +55,55 @@ const actions = [
   [baseActions.expose],
 ];
 
+const setViewToTableCover = async () => {
+  const oldViewMat = draw.viewer.viewMat;
+  const oldShowGrid = draw.config.showGrid;
+  const oldSize = draw.config.size;
+  const oldBack = draw.config.back;
+  const oldShowCompass = draw.config.showCompass;
+
+  const width = 900;
+  const height = width / tableCoverScale;
+  draw.config.size = { width, height };
+  draw.config.showGrid = false;
+  draw.config.back = undefined;
+  draw.config.showCompass = false;
+  await nextTick();
+  draw.initViewport();
+
+  return () => {
+    draw.config.size = oldSize;
+    draw.config.showGrid = oldShowGrid;
+    draw.config.back = oldBack;
+    draw.config.showCompass = oldShowCompass;
+    draw.viewer.setViewMat(oldViewMat);
+  };
+};
+
 const saveHandler = async () => {
-  await saveOverviewData({ store: draw!.getData(), viewport: draw!.viewer.transform.m });
+  const storeData = draw.getData();
+  await saveOverviewData({ store: storeData, viewport: draw!.viewer.transform.m });
+  const recover = await setViewToTableCover();
+  await nextTick();
   const blob = await getImage(draw, "image/png");
+  recover();
   const url = await uploadResourse(new File([blob], `tabulation-cover-${params.id}.png`));
-  saveTabulationCover(url);
+  await saveTabulationCover(url);
+  await refreshTabulationData();
+
+  const paperKey = tabulationData.value.paperKey as "a4";
+  const pConfig = getPaperConfig(
+    paperConfigs[paperKey].size,
+    paperConfigs[paperKey].scale
+  );
+  const tabStore = await getRepTabulationStore(
+    tabulationData.value.store,
+    pConfig.size,
+    pConfig.margin,
+    url
+  );
+  console.error("tabStore", tabStore);
+  await saveTabulationData({ ...tabulationData.value, paperKey, store: tabStore });
 };
 
 const gotoTabulation = async () => {

+ 7 - 13
src/example/fuse/views/overview/index.vue

@@ -23,32 +23,26 @@ import Header from "./header.vue";
 import Slide from "./slide.vue";
 import Container from "../../../components/container/container.vue";
 import ShowVR from "../../../components/show-vr.vue";
-import { getOverviewData, uploadResourse } from "../../req";
+import { uploadResourse } from "../../req";
 import { ref, watch } from "vue";
 import { Draw } from "../../../components/container/use-draw";
 import { Scene } from "../../../platform/platform-resource";
 import Dialog from "../../../dialog/dialog.vue";
+import { refreshOverviewData, overviewData } from "../../store";
 
 const full = ref(false);
 const draw = ref<Draw>();
 const vrScene = ref<Scene>();
 
 const init = async (draw: Draw) => {
-  draw.config.showCompass = true;
+  await refreshOverviewData();
+  draw.config.showCompass = false;
   draw.config.showLabelLine = true;
   draw.config.showComponentSize = true;
-
   draw.config.back = { color: "#f0f2f5", opacity: 1 };
-  // draw.config.border = {
-  //   margin: 10,
-  //   lineWidth: 1,
-  //   color: "#000",
-  //   opacity: 1,
-  // };
-
-  const data = await getOverviewData();
-  draw.store.setStore(data.store);
-  data.viewport && draw.viewer.setViewMat(data.viewport);
+  draw.store.setStore(overviewData.value.store);
+  overviewData.value.viewport && draw.viewer.setViewMat(overviewData.value.viewport);
 };
+
 watch(draw, (draw) => draw && init(draw));
 </script>

+ 190 - 0
src/example/fuse/views/tabulation/gen.ts

@@ -0,0 +1,190 @@
+import { defaultLayer } from "@/constant";
+import { getBaseItem } from "@/core/components/util";
+import { StoreData } from "@/core/store/store";
+import { getFixPosition } from "@/utils/bound";
+import { Size } from "@/utils/math";
+import { getImage } from "@/utils/resource";
+import { tableCoverKey, tableCoverWidth, tableTableKey, tableTitleKey } from "../../store";
+import { TableData } from "@/core/components/table";
+import { TextData } from "@/core/components/text";
+import { ImageData } from "@/core/components/image";
+
+const setCoverPosition = (
+  size: Size,
+  margin: number | number[],
+  cover: ImageData
+) => {
+  if (!Array.isArray(margin)) {
+    margin = [margin, margin, margin, margin];
+  }
+  const imagePos = getFixPosition(
+    {
+      left: cover.width / 2 + margin[3] + 70,
+      bottom: -cover.height / 2 + margin[2] + 70,
+    },
+    cover,
+    size
+  );
+  cover.mat[4] = imagePos.x;
+  cover.mat[5] = imagePos.y;
+};
+
+const setTablePosition = (
+  size: Size,
+  margin: number | number[],
+  table: TableData
+) => {
+  if (!Array.isArray(margin)) {
+    margin = [margin, margin, margin, margin];
+  }
+  const tablePos = getFixPosition(
+    { right: 40 + margin[1], bottom: 40 + margin[2] },
+    table,
+    size
+  );
+  table.mat[4] = tablePos.x;
+  table.mat[5] = tablePos.y;
+};
+
+const setTitlePosition = (
+  size: Size,
+  margin: number | number[],
+  title: TextData
+) => {
+  if (!Array.isArray(margin)) {
+    margin = [margin, margin, margin, margin];
+  }
+
+  const titlePos = {
+    x: (size.width - margin[3]) / 2 - 150 + margin[3],
+    y: 40 + margin[0],
+  };
+  title.mat[4] = titlePos.x;
+  title.mat[5] = titlePos.y;
+};
+
+const genDefaultCover = async (cover: string) => {
+  const image = await getImage(cover);
+  const coverData = {
+    ...getBaseItem(),
+    cornerRadius: 0,
+    strokeWidth: 1,
+    url: cover,
+    lock: true,
+    key: tableCoverKey,
+    zIndex: -1,
+    width: tableCoverWidth,
+    height: (image.height / image.width) * tableCoverWidth,
+    mat: [1, 0, 0, 1, 0, 0],
+  };
+  return coverData;
+};
+
+const genDefaultTable = () => {
+  const nameColl = {
+    width: 140,
+    height: 40,
+    padding: 13,
+    content: "",
+    align: "center",
+    fontSize: 14,
+    fontColor: "#000000",
+  };
+  const valueColl = { ...nameColl, width: 200 };
+  return {
+    ...getBaseItem(),
+    lock: true,
+    content: [
+      [{ ...nameColl, content: "案发时间" }, valueColl],
+      [{ ...nameColl, content: "案发地点" }, valueColl],
+      [{ ...nameColl, content: "绘图单位" }, valueColl],
+      [{ ...nameColl, content: "绘图人" }, valueColl],
+      [{ ...nameColl, content: "绘图时间" }, valueColl],
+    ],
+    key: tableTableKey,
+    width: nameColl.width + valueColl.width,
+    height: nameColl.height * 5,
+    mat: [1, 0, 0, 1, 0, 0],
+  };
+};
+
+export const genDefaultTitle = () => {
+  return {
+    ...getBaseItem(),
+    content: "默认标题",
+    lock: true,
+    width: 300,
+    heihgt: 42,
+    fontSize: 38,
+    key: tableTitleKey,
+    align: "center",
+    mat: [1, 0, 0, 1, 0, 0],
+  };
+};
+
+export const getRepTabulationStore = async (
+  store: StoreData,
+  size: Size,
+  margin: number | number[],
+  coverUrl?: string | null,
+  place = true
+) => {
+  const layers = store?.layers;
+  let layer = layers && layers[defaultLayer];
+
+  if (!layer) {
+    const titleData = genDefaultTitle();
+    const tableData = genDefaultTable();
+    setTitlePosition(size, margin, titleData);
+    setTablePosition(size, margin, tableData);
+    layer = { text: [titleData], table: [tableData] };
+
+    if (coverUrl) {
+      const imageData = await genDefaultCover(coverUrl);
+      setCoverPosition(size, margin, imageData);
+      layer.image = [imageData];
+    }
+  } else {
+    layer.image = layer.image || [];
+    const coverNdx = layer.image.findIndex(
+      (item) => item.key === tableCoverKey
+    );
+
+    if (coverUrl) {
+      if (!~coverNdx) {
+        const imageData = await genDefaultCover(coverUrl);
+        setCoverPosition(size, margin, imageData);
+        layer.image.push(imageData);
+      } else {
+        layer.image[coverNdx].url = coverUrl;
+      }
+    } else if (~coverNdx) {
+      layer.image.splice(coverNdx, 1);
+    }
+  }
+
+  if (place) {
+    const imageData = layer.image?.find(item => item.key === tableCoverKey)
+    imageData && setCoverPosition(size, margin, imageData)
+    const titleData = layer.text?.find(item => item.key === tableTitleKey)
+    titleData && setTitlePosition(size, margin, titleData)
+    const tableData = layer.table?.find(item => item.key === tableTableKey)
+    tableData && setTablePosition(size, margin, tableData)
+  }
+
+
+  if (layers) {
+    return {
+      ...store,
+      layers: {
+        ...layers,
+        [defaultLayer]: layer,
+      },
+    };
+  } else {
+    return {
+      ...store,
+      layers: { [defaultLayer]: layer },
+    };
+  }
+};

+ 70 - 4
src/example/fuse/views/tabulation/header.vue

@@ -1,5 +1,5 @@
 <template>
-  <Header :action-groups="actions" title="图纸">
+  <Header :action-groups="actions" title="图纸" :draw="draw">
     <template #saves>
       <el-button type="primary" @click="saveHandler">保存</el-button>
     </template>
@@ -7,12 +7,19 @@
 </template>
 
 <script lang="ts" setup>
-import { ElButton } from "element-plus";
+import { ElButton, ElMessage } from "element-plus";
 import { useDraw } from "../../../components/container/use-draw.ts";
 import Header from "../../../components/header/index.vue";
 import { getHeaderActions } from "../../../components/header/actions.ts";
 import { saveTabulationData } from "../../req.ts";
 import { Transform } from "konva/lib/Util";
+import { selectExposeFormat } from "@/example/dialog/expose/index.ts";
+import { grayscaleImage } from "@/utils/dom.ts";
+import saveAs from "@/utils/file-serve.ts";
+import { jsPDF } from "jspdf";
+import { getImage as getResourceImage } from "@/utils/resource.ts";
+import { nextTick } from "vue";
+import { tabulationData } from "../../store.ts";
 
 const draw = useDraw();
 
@@ -28,10 +35,69 @@ const actions = [
       },
     },
   ],
-  [baseActions.expose],
+  [
+    {
+      handler: async () => {
+        const ef = await selectExposeFormat();
+        const oldMat = draw.viewer.viewMat;
+        draw.viewer.setViewMat([1, 0, 0, 1, 0, 0]);
+        await nextTick();
+        const viewSize = draw.viewer.viewSize!;
+        const size = {
+          width: draw.stage!.width(),
+          height: draw.stage!.height(),
+        };
+        const rect = {
+          x: (size.width - viewSize.width) / 2,
+          y: (size.height - viewSize.height) / 2,
+          ...viewSize,
+        };
+        const format = "image/jpeg";
+
+        let fileBlob = await (draw.stage!.toBlob({
+          pixelRatio: 4,
+          mimeType: format,
+          quality: 1,
+          ...rect,
+        }) as Promise<Blob>);
+        draw.viewer.setViewMat(oldMat);
+
+        const filename = "canvas";
+        let ext = "jpg";
+
+        if (ef.color === "grayscale") {
+          const url = URL.createObjectURL(fileBlob);
+          const img = await getResourceImage(url);
+          fileBlob = await grayscaleImage(img, undefined, format);
+          URL.revokeObjectURL(url);
+        }
+
+        if (ef.format === "PDF") {
+          const url = URL.createObjectURL(fileBlob);
+          const img = await getResourceImage(url);
+          const pdf = new jsPDF(rect.width > rect.height ? "l" : "p", "px", [
+            rect.width,
+            rect.height,
+          ]);
+          pdf.addImage(img, format, 0, 0, rect.width, rect.height);
+          fileBlob = pdf.output("blob");
+          ext = "pdf";
+          URL.revokeObjectURL(url);
+        }
+        await saveAs(fileBlob, `${filename}.${ext}`);
+        ElMessage.success("导出成功");
+      },
+      text: "导出",
+      icon: "download",
+    },
+  ],
 ];
 
 const saveHandler = () => {
-  saveTabulationData({ store: draw!.getData(), viewport: draw!.viewer.transform.m });
+  saveTabulationData({
+    store: draw!.getData(),
+    viewport: draw!.viewer.transform.m,
+    paperKey: tabulationData.value.paperKey,
+  });
 };
 </script>

+ 9 - 114
src/example/fuse/views/tabulation/index.vue

@@ -5,10 +5,10 @@
     :ref="(d: any) => (draw = d?.draw)"
   >
     <template #header>
-      <Header @selectVR="(scene) => (vrScene = scene)" />
+      <Header @selectVR="(scene) => (vrScene = scene)" v-if="inited" />
     </template>
     <template #slide>
-      <Slide />
+      <Slide v-if="inited" />
     </template>
     <template #cover>
       <ShowVR :scene="vrScene" v-if="vrScene" ref="vr" @close="vrScene = undefined" />
@@ -23,131 +23,26 @@ import Header from "./header.vue";
 import Slide from "./slide.vue";
 import Container from "../../../components/container/container.vue";
 import ShowVR from "../../../components/show-vr.vue";
-import { getTabulationData, uploadResourse } from "../../req";
-import { computed, ref, watch } from "vue";
+import { uploadResourse } from "../../req";
+import { ref, watch } from "vue";
 import { Draw } from "../../../components/container/use-draw";
 import { Scene } from "../../../platform/platform-resource";
 import Dialog from "../../../dialog/dialog.vue";
-import { paper } from "../../../components/slide/actions";
-import { getImage } from "@/utils/resource";
-import { StoreData } from "@/core/store/store";
-import { defaultLayer } from "@/constant";
-import { getBaseItem } from "@/core/components/util";
-import { getFixPosition } from "@/utils/bound";
+import { tabulationData, refreshTabulationData } from "../../store";
 
 const full = ref(false);
 const draw = ref<Draw>();
 const vrScene = ref<Scene>();
 
-const margin = computed(() => {
-  let margin = draw.value!.config.margin!;
-  if (!Array.isArray(margin)) {
-    margin = [margin, margin, margin, margin];
-  }
-  return margin;
-});
-
-const addCover = async (url: string) => {
-  const image = await getImage(url);
-  const addWidth = 514;
-  const addHeight = (image.height / image.width) * 514;
-  draw.value!.addShape(
-    "image",
-    {
-      width: addWidth,
-      height: addHeight,
-      url,
-      lock: true,
-      zIndex: -1,
-    },
-    { x: margin.value[3] + 100 + addWidth / 2, y: margin.value[0] + 198 + addHeight / 2 },
-    true
-  );
-};
-
-const addDefaultData = () => {
-  const dom = draw.value!.stage!.container();
-  const nameColl = {
-    width: 140,
-    height: 40,
-    padding: 13,
-    content: "",
-    align: "center",
-    fontSize: 14,
-    fontColor: "#000000",
-  };
-  const valueColl = { ...nameColl, width: 200 };
-  const tableSize = {
-    width: nameColl.width + valueColl.width,
-    height: nameColl.height * 5,
-  };
-  const drawSize = {
-    width: dom.offsetWidth,
-    height: dom.offsetHeight,
-  };
-  const tablePos = getFixPosition(
-    { right: 40 + margin.value[1], bottom: 40 + margin.value[2] },
-    tableSize,
-    drawSize
-  );
-
-  const data: StoreData["layers"][0] = {
-    text: [
-      {
-        ...getBaseItem(),
-        content: "默认标题",
-        lock: true,
-        width: 300,
-        heihgt: 42,
-        fontSize: 38,
-        align: "center",
-        mat: [
-          1,
-          0,
-          0,
-          1,
-          (dom.offsetWidth - margin.value[3]) / 2 - 150 + margin.value[3],
-          40 + margin.value[0],
-        ],
-      },
-    ],
-    table: [
-      {
-        ...getBaseItem(),
-        lock: true,
-        content: [
-          [{ ...nameColl, content: "案发时间" }, valueColl],
-          [{ ...nameColl, content: "案发地点" }, valueColl],
-          [{ ...nameColl, content: "绘图单位" }, valueColl],
-          [{ ...nameColl, content: "绘图人" }, valueColl],
-          [{ ...nameColl, content: "绘图时间" }, valueColl],
-        ],
-        ...tableSize,
-        mat: [1, 0, 0, 1, tablePos.x, tablePos.y],
-      },
-    ],
-  };
-
-  return {
-    layers: { [defaultLayer]: data },
-  };
-};
-
+const inited = ref(false);
 const init = async (draw: Draw) => {
-  paper.children[1].handler(draw);
+  await refreshTabulationData();
   draw.config.showCompass = true;
   draw.config.showGrid = false;
   draw.config.showLabelLine = false;
   draw.config.showComponentSize = false;
-
-  const data = await getTabulationData();
-  if (!data.store.layers) {
-    draw.store.setStore(addDefaultData());
-  } else {
-    draw.store.setStore(data.store);
-    data.viewport && draw.viewer.setViewMat(data.viewport);
-  }
-  data.cover && addCover(data.cover);
+  draw.store.setStore(tabulationData.value.store);
+  inited.value = true;
 };
 watch(draw, (draw) => draw && init(draw));
 </script>

+ 34 - 4
src/example/fuse/views/tabulation/slide.vue

@@ -4,15 +4,45 @@
 
 <script lang="ts" setup>
 import Slide from "../../../components/slide/slide.vue";
-import Icons from "./slide-icons.vue";
-import { v4 as uuid } from "uuid";
-import { paper, text, serial, table, test } from "../../../components/slide/actions.ts";
+import {
+  paper as paperRaw,
+  text,
+  serial,
+  table,
+  test,
+} from "../../../components/slide/actions.ts";
 import { useDraw } from "../../../components/container/use-draw.ts";
-import { reactive } from "vue";
+import { nextTick, reactive, watch } from "vue";
+import { tabulationData } from "../../store";
+import { getRepTabulationStore } from "./gen.ts";
 
 const draw = useDraw();
+const paper = {
+  ...paperRaw,
+  children: paperRaw.children.map((item) => ({
+    ...item,
+    handler: () => (tabulationData.value.paperKey = item.key),
+  })),
+};
 const menus = reactive([paper, text, serial, table]);
 
+const setPaperAfterHandler = async (paperKey: string) => {
+  paperRaw.children.find((item) => item.key === paperKey)!.handler(draw);
+  await nextTick();
+  const data = await getRepTabulationStore(
+    draw.store.data,
+    draw.viewer.viewSize!,
+    draw.config.margin || 0,
+    tabulationData.value.cover,
+    true
+  );
+
+  draw.history.clear();
+  draw.store.setStore(data);
+};
+
+watch(() => tabulationData.value?.paperKey, setPaperAfterHandler, { immediate: true });
+
 if (import.meta.env.DEV) {
   menus.push(test);
 }

+ 47 - 8
src/utils/dom.ts

@@ -55,15 +55,54 @@ $fileInput.style = `
   position: absolute;
   display: none;
 `;
-export const selectFile = (multiple = false, accept = '') =>
+export const selectFile = (multiple = false, accept = "") =>
   new Promise<File[]>((resolve) => {
-    $fileInput.multiple = multiple
-    $fileInput.accept = accept
+    $fileInput.multiple = multiple;
+    $fileInput.accept = accept;
     $fileInput.onchange = () => {
-      $fileInput.accept = ''
-      $fileInput.multiple = false
+      $fileInput.accept = "";
+      $fileInput.multiple = false;
       resolve(Array.from($fileInput.files!));
-      $fileInput.value = ''
-    }
-    $fileInput.click()
+      $fileInput.value = "";
+    };
+    $fileInput.click();
   });
+
+export const grayscaleImage = (
+  img: HTMLImageElement,
+  rgbWeight = [0.3, 0.59, 0.11],
+  format = 'image/jpeg'
+): Promise<Blob> => {
+  const canvas = document.createElement("canvas");
+  canvas.width = img.width;
+  canvas.height = img.height;
+  const ctx = canvas.getContext("2d");
+
+  if (!ctx) {
+    throw new Error("Canvas 2D context not supported");
+  }
+
+  ctx.drawImage(img, 0, 0);
+  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+  const data = imageData.data;
+
+  for (let i = 0; i < data.length; i += 4) {
+    const r = data[i];
+    const g = data[i + 1];
+    const b = data[i + 2];
+    const gray = rgbWeight[0] * r + rgbWeight[1] * g + rgbWeight[2] * b;
+    data[i] = data[i + 1] = data[i + 2] = gray;
+  }
+
+  ctx.putImageData(imageData, 0, 0);
+
+  return new Promise((resolve, reject) => {
+    canvas.toBlob((grayBlob) => {
+      if (grayBlob) {
+        resolve(grayBlob);
+      } else {
+        reject(new Error("Failed to create grayscale Blob"));
+      }
+    }, format);
+  });
+};