浏览代码

新版本插件制作

bill 1 年之前
父节点
当前提交
6bb6ecb8ac

+ 92 - 27
src/app/test/index.vue

@@ -1,21 +1,22 @@
 <template>
-  <!-- <Teleport to="#right-pano">
-    <ElButton @click="board.clear()"> 清除 </ElButton>
-    <ElButton :disabled="!board.history.state.hasUndo" @click="board.history.undo()">
-      撤销
+  <Teleport to="#right-pano">
+    <!-- <ElButton @click="board.clear()"> 清除 </ElButton> -->
+    <ElButton :disabled="!historyState.hasUndo" @click="history.undo()"> 撤销 </ElButton>
+    <ElButton :disabled="!historyState.hasRedo" @click="history.redo()"> 重做 </ElButton>
+    <ElButton :disabled="!activeEntity" @click="activeEntity && activeEntity.destory()">
+      删除
     </ElButton>
-    <ElButton :disabled="!board.history.state.hasRedo" @click="board.history.redo()">
-      重做
-    </ElButton>
-    <ElButton @click="getData">获取数据</ElButton>
+    <ElButton @click="addPoiHandler('/poi-svgs/c.svg')">添加poi</ElButton>
+    <ElButton @click="stopAddPoiHandler"> 停止添加poi </ElButton>
 
+    <!-- <ElButton @click="getData">获取数据</ElButton>
     <ElButton v-if="!drawing" @click="drawHandler"> 绘画 </ElButton>
     <ElButton v-if="drawing" @click="drawing.cancel()"> 停止 </ElButton>
     <ElButton v-if="drawing" @click="drawing.submit()"> 提交 </ElButton>
 
     <template v-if="activeEntity">
       <ElButton @click="activeEntity.copy({ count: 5 })"> 向右复制5个 </ElButton>
-      <ElButton @click="activeEntity.del()"> 删除 </ElButton>
+      
     </template>
 
     <ElButton v-if="!addPoiState" @click="addPoiHandler('bzjg')">添加poi</ElButton>
@@ -24,21 +25,22 @@
     </template>
 
     <ElButton @click="board.showPois()"> 显示图例 </ElButton>
-    <ElButton @click="board.hidenPois()"> 隐藏图例 </ElButton></Teleport
-  > -->
-  <div
-    class="board-layout"
-    :style="{ width: width + 'px', height: height + 'px' }"
-    ref="containerRef"
-  ></div>
+    <ElButton @click="board.hidenPois()"> 隐藏图例 </ElButton>
+    -->
+  </Teleport>
+  <div class="board-layout" ref="containerRef"></div>
 </template>
 
 <script setup lang="ts">
-import { onMounted, shallowRef } from "vue";
+import { onMounted, reactive, ref, shallowRef } from "vue";
 import { Root } from "../../board/core/base/entity-root";
 import { SVGPois } from "../../board/components/poi/svg-pois";
+import { ViewerPlugin, HistoryPlugin, HistoryPluginProps } from "../../board/_plugins/";
 // import storeData from "./storeData.json";
-// import { ElButton } from "element-plus";
+import { ElButton } from "element-plus";
+import { SVGPoi } from "../../board/components/poi";
+import { continuityClick } from "../../board/core";
+import { ScalePlugin } from "../../board/_plugins/pixel-scale/pixel-scale";
 
 withDefaults(defineProps<{ width?: number; height?: number; pixelRation?: number }>(), {
   width: 320,
@@ -46,28 +48,91 @@ withDefaults(defineProps<{ width?: number; height?: number; pixelRation?: number
   pixelRation: 1,
 });
 
+const historyMethods: HistoryPluginProps<any> = {
+  init() {
+    return pois.attrib.map((item) => ({ ...item })) as typeof poiDatas;
+  },
+  get() {
+    return pois.attrib.map((item) => ({ ...item })) as typeof poiDatas;
+  },
+  set(data) {
+    pois.setAttrib(data);
+  },
+};
+
 const containerRef = shallowRef<HTMLDivElement>();
+const viewer = new ViewerPlugin({ move: true, scale: true });
+
+const poiDatas = [
+  { id: 2, x: 100, y: 100, type: "/poi-svgs/a.svg" },
+  { id: 3, x: 300, y: 100, type: "/poi-svgs/b.svg" },
+  { id: 4, x: 100, y: 300, type: "/poi-svgs/c.svg" },
+  { id: 5, x: 300, y: 300, type: "/poi-svgs/d.svg" },
+];
+
 const root = new Root<SVGPois>();
-const pois = new SVGPois({
-  attrib: [
-    { id: 2, x: 100, y: 100, type: "/poi-svgs/a.svg" },
-    { id: 3, x: 300, y: 100, type: "/poi-svgs/b.svg" },
-    { id: 4, x: 100, y: 300, type: "/poi-svgs/c.svg" },
-    { id: 5, x: 300, y: 300, type: "/poi-svgs/d.svg" },
-  ],
-});
+const pois = new SVGPois({ attrib: poiDatas });
+const activeEntity = ref<SVGPoi>();
+
 root.addChild(pois);
 root.openPointerEvents();
 
+let stopContinuityClick: () => void;
+const addPoiHandler = (type: string) => {
+  stopContinuityClick = continuityClick(
+    root,
+    (pos) => {
+      pois.addItem({
+        ...pos,
+        type,
+        id: Date.now(),
+      });
+    },
+    () => (stopContinuityClick = null)
+  );
+};
+const stopAddPoiHandler = () => stopContinuityClick && stopContinuityClick();
+
+root.bus.on("triggerFocus", (entity) => {
+  if (entity instanceof SVGPoi) {
+    activeEntity.value = entity;
+  } else {
+    activeEntity.value = null;
+  }
+});
+
+const history = new HistoryPlugin(historyMethods);
+const historyState = reactive({ hasUndo: false, hasRedo: false });
+const pixelScale = new ScalePlugin({
+  styles: {
+    right: 30,
+    bottom: 30,
+    getDisplay(pixel) {
+      return {
+        value: pixel,
+        unit: "px",
+      };
+    },
+  },
+});
+history.bus.on("stateChange", (state) => Object.assign(historyState, state));
+
 onMounted(() => {
   root.mount(containerRef.value);
+
+  history.setTree(root);
+  viewer.setTree(root);
+  pixelScale.setTree(root);
+  viewer.move({ x: 100, y: 100 });
+  viewer.move({ x: 100, y: 100 });
 });
 </script>
 
 <style lang="scss" scoped>
 .board-layout {
   position: absolute;
-
+  width: 100%;
+  height: 100%;
   canvas {
     display: block;
     width: 100%;

+ 196 - 0
src/board/_plugins/history/history.ts

@@ -0,0 +1,196 @@
+import { Root } from "../../core";
+import { History } from "stateshot";
+import { debounce, inRevise } from "../../shared";
+import { EditModeChange } from "../../core/base/entity-root-server";
+import mitt from "mitt";
+
+export type HistoryState = {
+  hasUndo: boolean;
+  hasRedo: boolean;
+};
+
+export class SingleHistory<T = any> {
+  props: HistoryPluginProps<T> & {
+    stateChange: (state: HistoryState) => void;
+  };
+  tree: Root;
+  history: History<{ data: T }>;
+  state = {
+    hasUndo: false,
+    hasRedo: false,
+  };
+
+  constructor(
+    props: HistoryPluginProps<T> & {
+      stateChange: (state: HistoryState) => void;
+    }
+  ) {
+    this.props = props;
+  }
+
+  private syncState() {
+    if (!this.history) return;
+    this.state.hasRedo = this.history.hasRedo;
+    this.state.hasUndo = this.history.hasUndo;
+    this.props.stateChange({ ...this.state });
+  }
+
+  setTree(tree: Root | null) {
+    this.tree = tree;
+    this.history = new History();
+  }
+
+  undo() {
+    if (this.history.hasUndo) {
+      this.history.undo();
+      this.props.set(this.get());
+      this.syncState();
+    }
+  }
+
+  get() {
+    return this.history.get()?.data;
+  }
+
+  redo() {
+    if (this.history.hasRedo) {
+      this.history.redo();
+      this.props.set(this.history.get().data);
+      this.syncState();
+    }
+  }
+
+  push(data: T) {
+    // if (inRevise(data, this.get())) {
+    this.history.pushSync({ data });
+    this.syncState();
+    // }
+  }
+
+  clear() {
+    this.history.reset();
+  }
+
+  unTimelyPush: () => void;
+  timelyPush() {
+    this.unTimelyPush && this.unTimelyPush();
+    const changeHandler = debounce((data: EditModeChange) => {
+      this.push(this.props.get(data));
+    }, 16);
+    this.tree.bus.on("entityChange", changeHandler);
+    this.unTimelyPush = () => {
+      this.tree.bus.off("entityChange", changeHandler);
+      this.unTimelyPush = null;
+    };
+  }
+
+  destory() {
+    this.unTimelyPush && this.unTimelyPush();
+    this.clear();
+  }
+}
+
+export type HistoryPluginProps<T> = {
+  init: () => T;
+  get: (args: EditModeChange) => T;
+  set: (data: T) => void;
+};
+
+export class HistoryPlugin<T = any> {
+  private historyStack: SingleHistory<T>[] = [];
+  bus = mitt<{ stateChange: HistoryState }>();
+  props: HistoryPluginProps<T>;
+  tree: Root;
+  hasRecovery = false;
+
+  constructor(props: HistoryPluginProps<T>) {
+    this.props = props;
+  }
+
+  get current() {
+    return this.historyStack[this.historyStack.length - 1];
+  }
+
+  setTree(tree: Root) {
+    if (tree === this.tree) return;
+    tree && tree.setHistory(this);
+    this.tree && this.tree.setHistory(null);
+
+    this.tree = tree;
+    this.historyStack.length = 0;
+    this.pushSingle();
+    this.timelyPush();
+    this.current.push(this.props.init());
+  }
+
+  private __prevState: HistoryState;
+  private pushSingle() {
+    const single = new SingleHistory({
+      ...this.props,
+      stateChange: (state) => {
+        if (inRevise(this.__prevState, state)) {
+          this.bus.emit("stateChange", state);
+          this.__prevState = state;
+        }
+      },
+    });
+    single.setTree(this.tree);
+    if (this.current) {
+      this.current.unTimelyPush();
+      single.push(this.current.get());
+    }
+    single.timelyPush();
+    this.historyStack.push(single);
+  }
+
+  private popSingle() {
+    const lastStack = this.historyStack.pop();
+    if (lastStack.state.hasUndo) {
+      this.current.push(lastStack.get());
+      console.log("合并历史");
+    }
+    lastStack.destory();
+    this.current.timelyPush();
+  }
+
+  private unTimelyPush: () => void;
+  private timelyPush() {
+    this.unTimelyPush && this.unTimelyPush();
+
+    const beforeHandler = this.pushSingle.bind(this);
+    const afterHandler = this.popSingle.bind(this);
+
+    this.tree.bus.on("entityChangeBefore", beforeHandler);
+    this.tree.bus.on("entityChangeAfter", afterHandler);
+
+    this.unTimelyPush = () => {
+      this.tree.bus.off("entityChangeBefore", beforeHandler);
+      this.tree.bus.off("entityChangeAfter", afterHandler);
+      this.unTimelyPush = null;
+    };
+  }
+
+  undo() {
+    this.hasRecovery = true;
+    this.current.undo();
+    this.hasRecovery = false;
+  }
+
+  redo() {
+    this.hasRecovery = true;
+    this.current.redo();
+    this.hasRecovery = false;
+  }
+
+  push(data: T) {
+    this.current.push(data);
+  }
+
+  clear() {
+    this.current.clear();
+  }
+
+  get() {
+    return this.current.get();
+  }
+}

+ 2 - 0
src/board/_plugins/index.ts

@@ -0,0 +1,2 @@
+export * from "./viewer/viewer";
+export * from "./history/history";

+ 29 - 0
src/board/_plugins/pixel-scale/helper.ts

@@ -0,0 +1,29 @@
+export const getAppropriateWidth = (
+  minWidth: number,
+  maxWidth: number,
+  pixelScale: number,
+  divisor = 0.1
+) => {
+  const index = Math.floor(Math.log10(minWidth * pixelScale));
+  const base = Math.pow(10, index);
+  const step = base * divisor;
+  let width: number;
+  let real: number;
+
+  let i = 0;
+  while (true) {
+    real = base + i * step;
+    width = real / pixelScale;
+
+    if (width > maxWidth) {
+      real = minWidth * pixelScale;
+      width = minWidth;
+      break;
+    } else if (width >= minWidth) {
+      break;
+    }
+    i++;
+  }
+
+  return { width, real };
+};

+ 111 - 0
src/board/_plugins/pixel-scale/line-scale-view.ts

@@ -0,0 +1,111 @@
+import { Entity } from "../../core/base/entity";
+import { Group } from "konva/lib/Group";
+import { Line } from "konva/lib/shapes/Line";
+import { Text } from "konva/lib/shapes/Text";
+import { getNorPosition, UNPosition } from "../../core/helper/dom";
+import { getAppropriateWidth } from "./helper";
+import { ScaleViewTypeProps } from "./pixel-scale";
+
+type ScaleViewStyles = UNPosition & {
+  padding?: number;
+  minWidth?: number;
+  maxWidth?: number;
+  getDisplay: (real: number) => { value: number; unit: string };
+  color?: string;
+  fontSize?: number;
+};
+
+export class LineScaleView extends Entity<undefined, Group> {
+  constructor(props: ScaleViewTypeProps<ScaleViewStyles>) {
+    super(props);
+    this.setStyles(props.styles);
+  }
+
+  styles: ScaleViewStyles;
+  setStyles(styles: ScaleViewStyles) {
+    this.styles = {
+      fontSize: 12,
+      padding: 3,
+      minWidth: 100,
+      maxWidth: 200,
+      color: "#000",
+      ...styles,
+    };
+  }
+
+  get height() {
+    return this.styles.fontSize + this.styles.padding;
+  }
+
+  pixelReal: number;
+  setPixelReal(pixelReal: number) {
+    this.pixelReal = pixelReal;
+  }
+
+  initShape() {
+    const group = new Group();
+    group.add(
+      new Line({
+        name: "scale-line",
+        points: [],
+        strokeScaleEnabled: false,
+        strokeWidth: 1.5,
+      }),
+      new Text({
+        name: "scale-text",
+        fontFamily: "Calibri",
+        x: 0,
+        y: 0,
+        align: "center",
+      })
+    );
+    return group;
+  }
+
+  diffRedraw(): void {
+    if (!this.pixelReal || !this.styles) {
+      this.visible(false);
+      return;
+    }
+
+    const { width, real } = getAppropriateWidth(
+      this.styles.minWidth,
+      this.styles.maxWidth,
+      this.pixelReal,
+      1
+    );
+
+    const display = this.styles.getDisplay(real);
+    const position = getNorPosition(
+      this.styles,
+      {
+        w: this.root.stage.width(),
+        h: this.root.stage.height(),
+      },
+      { w: width, h: this.height }
+    );
+
+    const h = this.height;
+    const w = width;
+
+    const halfH = h / 2;
+    this.visible(true);
+    this.shape
+      .findOne<Line>(".scale-line")
+      .points([0, halfH, 0, h, w, h, w, halfH])
+      .stroke(this.styles.color);
+
+    this.shape
+      .findOne<Text>(".scale-text")
+      .text(display.value + display.unit)
+      .fontSize(this.styles.fontSize)
+      .fill(this.styles.color)
+      .width(w)
+      .height(h - this.styles.fontSize);
+
+    this.shape.position({
+      x: position.left,
+      y: position.top,
+    });
+  }
+}

+ 77 - 0
src/board/_plugins/pixel-scale/pixel-scale.ts

@@ -0,0 +1,77 @@
+import { Entity, EntityProps } from "../../core/base/entity";
+import { Root } from "../../core/base/entity-root";
+import { debounce } from "../../shared";
+import { LineScaleView } from "./line-scale-view";
+
+export type ScaleViewTypeProps<T> = EntityProps<undefined> & { styles?: T };
+type ScaleView<T> = Entity & {
+  setStyles(styles: T): void;
+  setPixelReal(pixelReal: number): void;
+};
+type ScaleViewType<T> = new (props: ScaleViewTypeProps<T>) => ScaleView<T>;
+
+export type ScalePluginProps<T extends ScaleViewType<any>> =
+  T extends ScaleViewType<infer D> ? { type?: T; styles: D } : { type?: T };
+
+export class ScalePlugin<T extends ScaleViewType<any> = typeof LineScaleView> {
+  type: ScalePluginProps<T>["type"];
+  styles: ScalePluginProps<T>["styles"];
+  tree: Root;
+  view: InstanceType<ScalePluginProps<T>["type"]>;
+
+  constructor(props: ScalePluginProps<T>) {
+    this.type = props.type || LineScaleView;
+    this.styles = props.styles;
+  }
+
+  redraw() {
+    if (!this.tree || !this.view) return;
+    const start = this.tree.getReal({ x: 0, y: 0 });
+    const end = this.tree.getReal({ x: 1, y: 1 });
+    const pixelReal = end.x - start.x;
+    this.view.setPixelReal(pixelReal);
+    this.view.diffRedraw();
+  }
+
+  setStyles(styles: ScalePluginProps<T>["styles"]) {
+    this.styles = styles;
+    if (this.view) {
+      this.view.setStyles(styles);
+      this.view.diffRedraw();
+    }
+  }
+
+  private __changeTreeRelease: () => void;
+  setTree(tree: Root) {
+    if (this.tree === tree) return;
+    this.tree && this.__changeTreeRelease();
+
+    if (tree) {
+      const view = new this.type({
+        teleport: tree.fixLayer,
+        attrib: undefined,
+      });
+      view.setStyles(this.styles);
+      tree.addChild(view);
+
+      const redraw = debounce(this.redraw.bind(this), 32);
+      tree.bus.on("changeViewPort", redraw);
+      tree.bus.on("changeViewScale", redraw);
+
+      const release = () => {
+        this.__changeTreeRelease = null;
+        tree.bus.off("destroyBefore", release);
+        tree.bus.off("changeViewPort", redraw);
+        tree.bus.off("changeViewScale", redraw);
+        view.destory();
+        this.view = null;
+      };
+
+      tree.bus.on("destroyBefore", release);
+      this.view = view as any;
+      this.__changeTreeRelease = release;
+      this.tree = tree;
+      this.redraw();
+    }
+  }
+}

+ 224 - 0
src/board/_plugins/viewer/viewer.ts

@@ -0,0 +1,224 @@
+import { Root } from "../../core";
+import mitt from "mitt";
+import { Pos } from "../../core/type";
+import { alignPort, asyncTimeout, inRevise } from "../../shared";
+import { Transform } from "konva/lib/Util";
+import { openEntityDrag } from "../../core/base/entity-server";
+import { scaleListener } from "../../core/helper/dom";
+
+export type ViewerPluginProps = {
+  move?: boolean;
+  scale?: boolean;
+  bound?: number[];
+  padding?: number | number[];
+  retainScale?: boolean;
+};
+
+export class ViewerPlugin {
+  retainScale: boolean;
+  bound: number[];
+  padding: number | number[];
+  tree: Root;
+  props: Omit<ViewerPluginProps, "bound">;
+  viewMat: Transform;
+  partMat: Transform = new Transform();
+
+  bus = mitt<{ cameraChange: void }>();
+
+  constructor(props: ViewerPluginProps = {}) {
+    this.props = props;
+    this.props.move = props.move || false;
+    this.props.scale = props.scale || false;
+    this.viewMat = new Transform();
+    this.retainScale = props.retainScale || false;
+    this.bound = props.bound;
+  }
+
+  private dragDestory?: () => void;
+  disableMove() {
+    this.props.move = false;
+    this.dragDestory && this.dragDestory();
+  }
+  enableMove() {
+    this.disableMove();
+    this.props.move = true;
+    if (!this.tree) return;
+
+    this.dragDestory = openEntityDrag(
+      this.tree,
+      () => {
+        const initViewMat = this.viewMat;
+        const viewMat = this.partMat.copy().multiply(initViewMat).invert();
+        const initOffset = viewMat.point({ x: 0, y: 0 });
+
+        return {
+          move: (move, ev) => {
+            const offset = viewMat.point(move);
+            const diff = {
+              x: offset.x - initOffset.x,
+              y: offset.y - initOffset.y,
+            };
+            this.move(diff, initViewMat);
+            ev.preventDefault();
+          },
+        };
+      },
+      false
+    );
+  }
+
+  private scaleDestory?: () => void;
+  disableScale() {
+    this.props.scale = false;
+    this.scaleDestory && this.scaleDestory();
+  }
+
+  enableScale() {
+    this.disableScale();
+    this.props.scale = true;
+    if (!this.tree) return;
+
+    const dom = this.tree.container;
+    this.scaleDestory = scaleListener(dom, (pscale) => {
+      const viewMat = this.partMat.copy().multiply(this.viewMat).invert();
+      const center = viewMat.point(pscale.center);
+      this.scale(center, pscale.scale);
+    });
+  }
+
+  move(position: Pos, initMat = this.viewMat) {
+    this.mutMat(new Transform().translate(position.x, position.y), initMat);
+  }
+
+  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
+    );
+  }
+
+  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
+    );
+  }
+
+  mutMat(mat: number[] | Transform, initMat = this.viewMat) {
+    if (mat instanceof Transform) {
+      this.viewMat = initMat.copy().multiply(mat);
+    } else {
+      this.viewMat = initMat.copy().multiply(new Transform(mat));
+    }
+    this.update();
+    this.bus.emit("cameraChange");
+  }
+
+  autoBound(padding?: number | number[], stageNames = [".adsord-point"]) {
+    const positions = stageNames.flatMap((item) =>
+      this.tree.stage.find(item).map((s) => {
+        return s.position();
+      })
+    );
+    if (positions.length < 2) return;
+
+    const bound = [
+      Number.MAX_VALUE,
+      Number.MAX_VALUE,
+      -Number.MAX_VALUE,
+      -Number.MAX_VALUE,
+    ];
+    for (const position of positions) {
+      bound[0] = Math.min(bound[0], position.x);
+      bound[1] = Math.min(bound[1], position.y);
+      bound[2] = Math.max(bound[2], position.x);
+      bound[3] = Math.max(bound[3], position.y);
+    }
+    this.bound = bound;
+    this.padding = padding;
+    this.genPartMat();
+  }
+
+  private genPartMat() {
+    if (this.bound && this.tree) {
+      const mat = alignPort(
+        this.bound,
+        [0, 0, this.tree.stage.width(), this.tree.stage.height()],
+        this.retainScale,
+        this.padding
+      );
+      this.partMat = new Transform()
+        .translate(mat.translation.x, mat.translation.y)
+        .scale(mat.scale.x, mat.scale.y);
+      this.update();
+    }
+  }
+
+  setBound(bound: number[]) {
+    if (inRevise(bound, this.bound)) {
+      this.bound = bound;
+      this.genPartMat();
+    }
+  }
+
+  setRetainScale(retainScale: boolean) {
+    if (retainScale !== this.retainScale) {
+      this.retainScale = retainScale;
+      this.genPartMat();
+    }
+  }
+
+  setPadding(padding: number | number[]) {
+    if (padding !== this.padding) {
+      this.genPartMat();
+    }
+  }
+
+  setTree(tree: Root) {
+    const init = async () => {
+      tree.stage.visible(false);
+      await asyncTimeout(160);
+      tree.stage.visible(true);
+      this.tree = tree;
+
+      if (this.props.move) {
+        this.enableMove();
+      }
+      if (this.props.scale) {
+        this.enableScale();
+      }
+      this.genPartMat();
+      if (this.dirtyMat) {
+        this.update();
+      }
+      tree.bus.off("mounted", init);
+    };
+    if (tree.isMounted) {
+      init();
+    } else {
+      tree.bus.on("mounted", init);
+    }
+  }
+
+  private dirtyMat = false;
+  update() {
+    if (this.tree) {
+      const mat = this.partMat.copy().multiply(this.viewMat);
+      this.tree.updateViewMat(mat);
+    } else {
+      this.dirtyMat = true;
+    }
+  }
+}

+ 6 - 8
src/board/components/poi/poi.ts

@@ -5,7 +5,7 @@ import {
   EntityTree,
 } from "../../core/base/entity";
 import konva from "konva";
-import { openEntityDrag } from "../../core/helper/entity-drag";
+import { openEntityDrag } from "../../core/base/entity-server";
 
 export type PoiData = {
   id?: EntityKey;
@@ -14,12 +14,10 @@ export type PoiData = {
   rotate?: number;
 };
 
-export class Poi<S extends konva.Group | konva.Shape> extends Entity<
-  PoiData,
-  S,
-  EntityTree,
-  EntityProps<PoiData> & { shape: S }
-> {
+export class Poi<
+  S extends konva.Group | konva.Shape,
+  K extends PoiData = PoiData
+> extends Entity<K, S, EntityTree, EntityProps<K> & { shape: S }> {
   initShape() {
     return this.props.shape;
   }
@@ -35,7 +33,7 @@ export class Poi<S extends konva.Group | konva.Shape> extends Entity<
   disableDrag = () => {};
   enableDrag() {
     this.disableDrag = openEntityDrag(this, () => ({
-      move: (pos) => this.setAttrib(pos),
+      move: (pos) => this.setAttrib(pos as K),
     }));
   }
 }

+ 1 - 1
src/board/components/poi/svg-poi.ts

@@ -11,7 +11,7 @@ export type SVGPoiData = PoiData & {
   type: string;
 };
 
-export class SVGPoi extends Poi<konva.Group> {
+export class SVGPoi extends Poi<konva.Group, SVGPoiData> {
   private act: PathsShapeAct;
 
   constructor(props: EntityProps<SVGPoiData>) {

+ 6 - 2
src/board/core/base/entity-factory.ts

@@ -85,13 +85,18 @@ export const incEntitysFactoryGenerate = <
 
   const add = (attrib: T, key: string) => {
     const addEntity = entityFactory(attrib, key, Type, parent, extra, created);
+    addEntity.bus.on("destroyed", () => {
+      delete cache[key];
+      const ndx = oldKeys.indexOf(key);
+      if (~ndx) oldKeys.splice(ndx, 1);
+    });
     return (cache[key] = addEntity);
   };
 
   return (attribsRaw: T[]) => {
     const attribs = attribsRaw as any[];
     if (!inited && attribs.length && typeof attribs[0] === "object") {
-      useIndex = "id" in attribs[0];
+      useIndex = !("id" in attribs[0]);
       inited = true;
     }
 
@@ -113,7 +118,6 @@ export const incEntitysFactoryGenerate = <
     }
 
     const { addPort, delPort, holdPort } = getChangePart(newKeys, oldKeys);
-
     const dels = delPort.map(destory);
     const adds = addPort.map((key) => add(findAttrib(attribs, key), key));
     const upds = holdPort.map((key) => {

+ 29 - 1
src/board/core/base/entity-group.ts

@@ -26,6 +26,34 @@ export class EntityGroup<
   }
 
   diffRedraw() {
-    return this.incFactory(this.attrib);
+    const { adds } = this.incFactory(this.attrib);
+
+    adds.forEach((add) => {
+      add.bus.on("destroyed", () => {
+        const ndx = this.attrib.indexOf(add.attrib);
+        if (~ndx) {
+          this.attrib.splice(ndx, 1);
+        }
+      });
+      add.bus.on("updateAttrib", ([newAttrib, oldAttrib]) => {
+        const ndx = this.attrib.indexOf(oldAttrib);
+        if (~ndx) {
+          this.attrib[ndx] = newAttrib;
+        }
+      });
+    });
+  }
+
+  addItem(data: Data<T>) {
+    this.attrib.push(data);
+    this.diffRedraw();
+  }
+
+  delItem(data: Data<T>) {
+    const ndx = this.attrib.indexOf(data);
+    if (~ndx) {
+      this.attrib.splice(ndx, 1);
+      this.diffRedraw();
+    }
   }
 }

+ 287 - 61
src/board/core/base/entity-root-server.ts

@@ -3,6 +3,14 @@ import { Entity } from "../base/entity";
 import { Root } from "./entity-root";
 import { Pos } from "../type";
 import { Transform } from "konva/lib/Util";
+import {
+  canEntityReply,
+  onEntity,
+  openOnEntityTree,
+  TreeEvent,
+} from "../event";
+import { findEntityByShape } from "./entity-server";
+import { debounce, getChangePart, mergeFuns } from "../../shared";
 
 // 指定某些entity可编辑
 export const openOnlyMode = (
@@ -10,31 +18,38 @@ export const openOnlyMode = (
   _entitys: Entity[],
   includeNews = true
 ) => {
-  const prevRootReply = root.replyEvents;
-
   const entitys: (Entity | null)[] = [];
   const prevEntitysReply: Entity["replyEvents"][] = [];
-  const pushEntity = (entity: Entity) => {
+  const setEntityReply = (entity: Entity, reply: Entity["replyEvents"]) => {
     const ndx = entitys.length;
     entitys[ndx] = entity;
     prevEntitysReply[ndx] = entity.replyEvents;
-    entity.replyEvents = "all";
+    entity.replyEvents = reply;
   };
-  const delEntity = (entity: Entity) => {
+
+  root.children.forEach((item) => setEntityReply(item, "none"));
+  _entitys.forEach((item) => setEntityReply(item, "all"));
+
+  const delHandler = (entity: Entity) => {
     const ndx = entitys.indexOf(entity);
     prevEntitysReply[ndx] = null;
     entitys[ndx] = null;
   };
 
-  _entitys.forEach(pushEntity);
-  // 新增的entity可以使用事件
-  includeNews && root.bus.on("addEntity", pushEntity);
-  root.bus.on("delEntity", delEntity);
+  const addHandler = (entity: Entity) => {
+    if (includeNews) {
+      setEntityReply(entity, "all");
+    } else if (root.children.includes(entity)) {
+      setEntityReply(entity, "none");
+    }
+  };
+
+  root.bus.on("delEntity", delHandler);
+  root.bus.on("addEntity", addHandler);
 
   return () => {
-    root.bus.off("addEntity", pushEntity);
-    root.bus.off("delEntity", delEntity);
-    root.replyEvents = prevRootReply;
+    root.bus.off("delEntity", delHandler);
+    root.bus.off("addEntity", addHandler);
     entitys.forEach((entity, ndx) => {
       if (entity) {
         entity.replyEvents = prevEntitysReply[ndx];
@@ -44,76 +59,152 @@ export const openOnlyMode = (
 };
 
 export type EditModeProps = {
-  entitys: Entity[];
-  only: boolean;
+  entitys?: Entity[];
+  only?: boolean;
   includeNews?: boolean;
 };
-export const openEditMode = async (
+export type EditModeChange = {
+  addEntitys?: Entity[];
+  setEntitys?: Entity[];
+  delEntitys?: Entity[];
+};
+
+export const openEditMode = (
   root: Root,
   main: () => any,
-  props: EditModeProps = { entitys: [], only: false, includeNews: false }
+  props: EditModeProps = {
+    entitys: [],
+    only: false,
+    includeNews: false,
+  }
 ) => {
-  root.bus.emit("dataChangeBefore");
+  if (!props.entitys) props.entitys = [];
+  if (!props.only) props.only = false;
+  if (!props.includeNews) props.includeNews = false;
+
+  root.bus.emit("entityChangeBefore");
   const quitOnlyMode =
     props.only && openOnlyMode(root, props.entitys, props.includeNews);
 
-  const addEntitys = [];
-  const setEntitys = [];
-  const delEntitys = [];
-  const addHandler = (entity: Entity) => {
-    addEntitys.push(entity);
+  let state = "normal";
+  let resolve: () => void;
+  const interrupt = () => {
+    state = "interrupt";
+    resolve();
   };
-  const setHandler = (entity: Entity) => {
-    if (
-      !addEntitys.includes(entity) &&
-      !delEntitys.includes(entity) &&
-      !setEntitys.includes(entity)
-    ) {
-      setEntitys.push(entity);
+
+  const destoryAutoEmit = root.history && autoEmitDataChange(root);
+
+  const interruptPromise = new Promise<void>((r) => (resolve = r));
+  const draw = Promise.any([interruptPromise, Promise.resolve(main())]).then(
+    () => {
+      quitOnlyMode && quitOnlyMode();
+      destoryAutoEmit && destoryAutoEmit();
+      setTimeout(() => {
+        root.bus.emit("entityChangeAfter");
+      });
+      return { state };
     }
+  );
+
+  return {
+    draw,
+    interrupt,
   };
-  const delHandler = (entity: Entity) => {
-    const delNdx = delEntitys.indexOf(entity);
-    if (~delNdx) return;
+};
 
-    const addNdx = addEntitys.indexOf(entity);
-    if (~addNdx) addEntitys.splice(addNdx, 1);
+export const openEditModePacking = (root: Root, props?: EditModeProps) => {
+  let complete: () => void;
+  const { interrupt } = openEditMode(
+    root,
+    () => {
+      return new Promise<void>((r) => (complete = r));
+    },
+    props
+  );
+  return {
+    interrupt,
+    complete,
+  };
+};
 
-    const setNdx = setEntitys.indexOf(entity);
-    if (~setNdx) setEntitys.splice(setNdx, 1);
+const autoedRoots = new WeakMap<
+  Root,
+  {
+    quete: number;
+    destory: () => void;
+    pause: () => void;
+    continue: () => void;
+  }
+>();
+export const hasAutoEmitDataChange = (root: Root) => {
+  return autoedRoots.has(root);
+};
+export const pauseAutoEmitDataChange = (root: Root) => {
+  if (autoedRoots.has(root)) {
+    autoedRoots.get(root).pause();
+  }
+};
+export const continueAutoEmitDataChange = (root: Root) => {
+  if (autoedRoots.has(root)) {
+    autoedRoots.get(root).continue();
+  }
+};
+export const autoEmitDataChange = (root: Root) => {
+  if (autoedRoots.has(root)) {
+    const old = autoedRoots.get(root);
+    old.quete++;
+    return old.destory;
+  }
+  let pause = false;
+  const addHandler = (entity: Entity) =>
+    pause ||
+    (!root.history?.hasRecovery &&
+      root.bus.emit("entityChange", { addEntitys: [entity] }));
+  const delHandler = (entity: Entity) =>
+    pause ||
+    (!root.history?.hasRecovery &&
+      root.bus.emit("entityChange", { delEntitys: [entity] }));
 
-    delEntitys.push(entity);
+  const changeEntitys = new Set<Entity>();
+  const setHandler = (entity: Entity) => {
+    if (!pause) {
+      if (!entity.root.dragEntity) {
+        !root.history?.hasRecovery &&
+          root.bus.emit("entityChange", { setEntitys: [entity] });
+      } else {
+        changeEntitys.add(entity);
+      }
+    }
+  };
+  const triggerDragHandler = (entity: Entity) => {
+    if (!entity) {
+      changeEntitys.forEach(setHandler);
+    }
   };
 
   root.bus.on("addEntity", addHandler);
-  root.bus.on("updateEntity", setHandler);
   root.bus.on("delEntity", delHandler);
+  root.bus.on("setEntity", setHandler);
+  root.bus.on("triggerDrag", triggerDragHandler);
 
-  let state = "normal";
-  const interrupt = new Promise<void>((resolve) => {
-    state = "interrupt";
-    resolve();
-  });
+  const destory = () => {
+    if (--autoedRoots.get(root).quete === 0) {
+      root.bus.off("setEntity", setHandler);
+      root.bus.off("delEntity", delHandler);
+      root.bus.off("addEntity", addHandler);
+      root.bus.off("triggerDrag", triggerDragHandler);
+      autoedRoots.delete(root);
+    }
+  };
 
-  const draw = Promise.any([interrupt, Promise.resolve(main())]).then(() => {
-    root.bus.off("addEntity", addHandler);
-    root.bus.off("updateEntity", setHandler);
-    root.bus.off("delEntity", addHandler);
-    quitOnlyMode && quitOnlyMode();
-
-    const change = {
-      addEntitys,
-      delEntitys,
-      setEntitys,
-    };
-    root.bus.emit("dataChangeAfter", change);
-    return { state, change };
+  autoedRoots.set(root, {
+    quete: 1,
+    destory,
+    pause: () => (pause = true),
+    continue: () => (pause = false),
   });
-
-  return {
-    draw,
-    interrupt,
-  };
+  return destory;
 };
 
 const cursorResources = {
@@ -266,3 +357,138 @@ export const currentConstant = new Proxy(
     },
   }
 );
+
+export const injectPointerEvents = (root: Root) => {
+  const store = {
+    hovers: new Set<Entity>(),
+    focus: new Set<Entity>(),
+    drag: null as Entity,
+  };
+  const oldStore = {
+    hovers: [] as Entity[],
+    focus: [] as Entity[],
+    drag: null as Entity,
+  };
+
+  const emit = debounce(() => {
+    const hovers = [...store.hovers];
+    const focus = [...store.focus];
+    const hoverChange = getChangePart(hovers, oldStore.hovers);
+    const focusChange = getChangePart(focus, oldStore.focus);
+
+    hoverChange.addPort.forEach((entity) => entity.bus.emit("hover"));
+    hoverChange.delPort.forEach((entity) => entity.bus.emit("leave"));
+    focusChange.addPort.forEach((entity) => entity.bus.emit("focus"));
+    focusChange.delPort.forEach((entity) => entity.bus.emit("blur"));
+
+    if (oldStore.drag !== store.drag) {
+      oldStore.drag && oldStore.drag.bus.emit("drop");
+      store.drag && store.drag.bus.emit("drag");
+    }
+
+    oldStore.drag = store.drag;
+    oldStore.hovers = hovers;
+    oldStore.focus = focus;
+  }, 16);
+
+  const needReleases = [
+    openOnEntityTree(root, "mouseover"),
+    openOnEntityTree(root, "mouseout"),
+    openOnEntityTree(root, "click"),
+    openOnEntityTree(root, "touchend"),
+    onEntity(root, "dragstart", (ev) => {
+      const hit = findEntityByShape(root, ev.target);
+      if (canEntityReply(hit)) {
+        store.drag = hit;
+        root.dragEntity = hit;
+        root.bus.emit("triggerDrag", hit);
+        emit();
+      }
+    }),
+    onEntity(root, "dragend", () => {
+      if (store.drag && canEntityReply(store.drag)) {
+        store.drag = null;
+        root.dragEntity = null;
+        root.bus.emit("triggerDrag", null);
+        emit();
+      }
+    }),
+  ];
+
+  const enterHandler = ({ paths }: TreeEvent) => {
+    paths.forEach((entity) => {
+      if (canEntityReply(entity)) {
+        store.hovers.add(entity);
+      }
+    });
+    emit();
+  };
+  const leaveHandler = ({ paths }: TreeEvent) => {
+    paths.forEach((entity) => {
+      if (canEntityReply(entity)) {
+        store.hovers.delete(entity);
+      }
+    });
+    emit();
+  };
+  const clickHandler = ({ paths }: TreeEvent) => {
+    store.focus.clear();
+    if (canEntityReply(paths[0])) {
+      root.bus.emit("triggerFocus", paths[0]);
+    }
+    paths.forEach((entity) => {
+      if (canEntityReply(entity)) {
+        store.focus.add(entity);
+      }
+    });
+    emit();
+  };
+
+  root.bus.on("mouseover" as any, enterHandler);
+  root.bus.on("mouseout" as any, leaveHandler);
+  root.bus.on("click", clickHandler);
+  root.bus.on("touchend", clickHandler);
+
+  const destory = () => {
+    mergeFuns(needReleases)();
+    root.bus.off("mouseover" as any, enterHandler);
+    root.bus.off("mouseout" as any, leaveHandler);
+    root.bus.off("click", clickHandler);
+    root.bus.off("touchend", clickHandler);
+  };
+
+  root.bus.on("destroyBefore", destory);
+  return {
+    focus(...entitys: Entity[]) {
+      store.focus.clear();
+      entitys.forEach((entity) => store.focus.add(entity));
+      emit();
+    },
+    blur(...entitys: Entity[]) {
+      entitys.forEach((entity) => store.focus.delete(entity));
+      emit();
+    },
+    hover(...entitys: Entity[]) {
+      store.hovers.clear();
+      entitys.forEach((entity) => store.hovers.add(entity));
+      emit();
+    },
+    leave(...entitys: Entity[]) {
+      entitys.forEach((entity) => store.hovers.delete(entity));
+      emit();
+    },
+    drag(entity: Entity) {
+      store.drag = entity;
+      emit();
+    },
+    drop(entity: Entity) {
+      if (store.drag === entity) {
+        store.drag = entity;
+        emit();
+      }
+    },
+    destory,
+  };
+};
+
+export type PointerEvents = ReturnType<typeof injectPointerEvents>;

+ 110 - 52
src/board/core/base/entity-root.ts

@@ -4,24 +4,28 @@ import { entityMount } from "./entity-server";
 import { Emitter, Pos, RootMat } from "../type";
 import {
   EditModeProps,
-  openEditMode,
   injectSetCursor,
   injectConstant,
+  EditModeChange,
+  openEditModePacking,
+  injectPointerEvents,
+  PointerEvents,
 } from "./entity-root-server";
 import { inRevise } from "../../shared";
-import { injectPointerEvents, PointerEvents } from "../event";
 import { Transform } from "konva/lib/Util";
 
 export type RootEvent = EntityEvent & {
-  updateEntity: Entity;
+  triggerFocus: Entity;
+  triggerDrag: Entity | null;
+
   addEntity: Entity;
   delEntity: Entity;
-  dataChangeBefore: void;
-  dataChangeAfter: {
-    addEntitys: Entity[];
-    delEntitys: Entity[];
-    setEntitys: Entity[];
-  };
+  setEntity: Entity;
+
+  entityChangeBefore: void;
+  entityChange: EditModeChange;
+  entityChangeAfter: void;
+
   changeView: RootMat;
   changeViewPort: Pos;
   changeViewScale: RootMat["scale"];
@@ -34,12 +38,15 @@ export class Root<T extends Entity = any> extends Entity<
   konva.Layer,
   EntityTree<never, T, Root<Entity>>
 > {
+  dragEntity: Entity | null = null;
   container?: HTMLDivElement;
   stage: konva.Stage;
+  fixLayer: konva.Layer;
   tempLayer: konva.Layer;
   bus: Emitter<RootEvent>;
   mat: Transform;
   invMat: Transform;
+  tempComtainer = document.createElement("div");
 
   constructor() {
     super({
@@ -49,8 +56,17 @@ export class Root<T extends Entity = any> extends Entity<
     });
     this.root = this;
     this.stage = new konva.Stage({ container: document.createElement("div") });
+    this.fixLayer = new konva.Layer();
     this.mat = this.stage.getTransform();
     this.invMat = this.mat.copy().invert();
+
+    this.stage.add(this.fixLayer);
+    this.setTeleport(this.stage);
+  }
+
+  history: null | { hasRecovery: boolean };
+  setHistory(history: null | { hasRecovery: boolean }) {
+    this.history = history;
   }
 
   setCursor = injectSetCursor(this);
@@ -67,68 +83,110 @@ export class Root<T extends Entity = any> extends Entity<
     this.trigger && this.trigger.destory();
   }
 
-  editMode(main: () => void, props?: EditModeProps) {
-    return openEditMode(this, main, props);
+  private __editPacking: ReturnType<typeof openEditModePacking>;
+  get hasEditMode() {
+    return !!this.__editPacking;
+  }
+  editMode(props?: EditModeProps) {
+    if (this.__editPacking) {
+      throw "当前正在编辑模式";
+    }
+    this.__editPacking = openEditModePacking(this, props);
+  }
+
+  leaveEditMode() {
+    if (this.__editPacking) {
+      this.__editPacking.complete();
+      this.__editPacking = null;
+    }
+  }
+  interruptEditMode() {
+    if (this.__editPacking) {
+      this.__editPacking.interrupt();
+      this.__editPacking = null;
+    }
   }
 
   getPixel(real: Pos) {
-    return this.stage.getTransform().point(real);
+    return this.mat.point(real);
   }
 
   getReal(pixel: Pos) {
-    return this.stage.getTransform().invert().point(pixel);
+    return this.invMat.point(pixel);
   }
 
   constant = injectConstant(this);
-  mount(container: HTMLDivElement): void {
+
+  private __changeContainerRelease: () => void;
+  mount(container: HTMLDivElement = this.tempComtainer): void {
     if (container === this.container && this.isMounted) return;
     if (!container) throw "mount 需要 container";
-    this.container = container;
-
-    this.setTeleport(this.stage);
-    const w = this.container.offsetWidth;
-    const h = this.container.offsetHeight;
 
-    if (w !== this.stage.width() || h !== this.stage.height()) {
-      this.stage.width(w);
-      this.stage.height(h);
+    if (this.container) {
+      this.__changeContainerRelease();
+    }
+    const changeSize = () => {
+      const w = container.offsetWidth;
+      const h = container.offsetHeight;
+      if (w !== this.stage.width() || h !== this.stage.height()) {
+        this.stage.width(w);
+        this.stage.height(h);
+        this.bus.emit("changeViewPort", { x: w, y: h });
+      }
+    };
+
+    if (container) {
+      this.stage.setContainer(container);
+      changeSize();
+      window.addEventListener("resize", changeSize);
+      this.__changeContainerRelease = () => {
+        window.removeEventListener("resize", changeSize);
+      };
+      this.container = container;
+      this.isMounted || entityMount(this);
     }
-    console.log(w, h);
-
-    this.stage.setContainer(this.container);
-    this.isMounted || entityMount(this);
-    this.bus.emit("changeViewPort", { x: w, y: h });
   }
 
-  updateViewMat(mat: { position?: Pos; scale?: Pos; rotation?: number }) {
-    const scaleChange =
-      "scale" in mat && inRevise(mat.scale, this.stage.scale());
-    const positionChange =
-      "position" in mat && inRevise(mat.position, this.stage.position());
-    const rotateChange =
-      "rotation" in mat && inRevise(mat.rotation, this.stage.rotation());
-
-    if (rotateChange || scaleChange || positionChange) {
-      this.bus.emit("changeView", {
-        scale: this.stage.scale(),
-        position: this.stage.position(),
-        rotation: this.stage.rotation(),
-      });
-      this.mat = this.stage.getTransform();
-      this.invMat = this.mat.copy().invert();
-    }
+  updateViewMat(transform: Transform) {
+    const mat = transform.decompose();
+    const scale = {
+      x: mat.scaleX,
+      y: mat.scaleY,
+    };
+    const position = {
+      x: mat.x,
+      y: mat.y,
+    };
+    const rotation = mat.rotation;
+
+    const scaleChange = inRevise(scale, this.shape.scale());
+    const positionChange = inRevise(position, this.shape.position());
+    const rotateChange = inRevise(rotation, this.shape.rotation());
+
+    this.shape.scale(scale);
+    this.shape.rotate(rotation);
+    this.shape.position(position);
+
+    this.mat = transform.copy();
+    this.invMat = transform.copy().invert();
+    this.shape.draw();
 
-    if (rotateChange) {
-      this.stage.rotate(mat.rotation);
-      this.bus.emit("changeViewRotation", mat.rotation);
-    }
     if (scaleChange) {
-      this.stage.scale(mat.scale);
-      this.bus.emit("changeViewScale", mat.scale);
+      this.bus.emit("changeViewScale", scale);
     }
     if (positionChange) {
-      this.stage.position(mat.scale);
-      this.bus.emit("changeViewPosition", mat.position);
+      this.bus.emit("changeViewPosition", position);
+    }
+    if (rotateChange) {
+      this.bus.emit("changeViewRotation", rotation);
+    }
+
+    if (rotateChange || scaleChange || positionChange) {
+      this.bus.emit("changeView", {
+        scale: this.shape.scale(),
+        position: this.shape.position(),
+        rotation: this.shape.rotation(),
+      });
     }
   }
 }

+ 123 - 2
src/board/core/base/entity-server.ts

@@ -1,9 +1,17 @@
 import { Layer } from "konva/lib/Layer";
-import { mergeFuns } from "../../shared";
+import { debounce, mergeFuns } from "../../shared";
 import { Entity, EntityShape, EntityTransmit } from "./entity";
 import { Root } from "./entity-root";
 import { Stage } from "konva/lib/Stage";
 import { contain } from "../helper/shape";
+import {
+  autoEmitDataChange,
+  hasAutoEmitDataChange,
+} from "./entity-root-server";
+import { canEntityReply } from "../event";
+import { Pos } from "../type";
+import { KonvaEventObject } from "konva/lib/Node";
+import { getOffset } from "../helper/dom";
 
 export const traversEntityTree = (
   entity: Entity,
@@ -227,6 +235,119 @@ export const mountEntityTree = <T extends Entity>(
     setEntityParent(parent, entity);
   }
   entityMount(entity);
-  entity.root && entity.root.bus.emit("addEntity", entity);
+  if (entity.root) {
+    entity.root.bus.emit("addEntity", entity);
+    if (
+      entity.root.history &&
+      !entity.root.history.hasRecovery &&
+      !hasAutoEmitDataChange(entity.root)
+    ) {
+      entity.root.bus.emit("entityChange", { addEntitys: [this] });
+    }
+  }
   return entity;
 };
+
+export type DragHandlers = (ev: KonvaEventObject<any>) => {
+  move: (move: Pos, ev: MouseEvent | TouchEvent) => void;
+  end?: (ev: KonvaEventObject<any>) => void;
+};
+
+export const openEntityDrag = <T extends Entity>(
+  entity: T,
+  getHandlers: DragHandlers,
+  trasnform = true
+) => {
+  const shape = entity instanceof Root ? entity.stage : entity.shape;
+  shape.draggable(true);
+
+  let canReply = false;
+  let moveHandler: ReturnType<DragHandlers>["move"];
+  let endHandler: ReturnType<DragHandlers>["end"];
+  let destoryAutoEmit: () => void;
+  let start: Pos;
+
+  shape.on("dragstart.drag", (ev) => {
+    canReply = canEntityReply(entity);
+    if (canReply) {
+      const handlers = getHandlers(ev);
+      moveHandler = handlers.move;
+      endHandler = handlers.end;
+      if (entity.root.history) {
+        destoryAutoEmit = autoEmitDataChange(entity.root);
+      }
+      start = getOffset(ev.evt);
+    }
+  });
+
+  shape.dragBoundFunc((pos, ev) => {
+    if (canReply) {
+      const end = getOffset(ev);
+      const move = {
+        x: end.x - start.x,
+        y: end.y - start.y,
+      };
+      moveHandler(trasnform ? entity.root.invMat.point(pos) : move, ev);
+    }
+    return shape.absolutePosition();
+  });
+
+  shape.on("dragend.drag", (ev) => {
+    moveHandler = null;
+    canReply = false;
+    start = null;
+    endHandler && endHandler(ev);
+    destoryAutoEmit && destoryAutoEmit();
+  });
+
+  return () => {
+    shape.draggable(false);
+    shape.off("dragstart.drag dragend.drag");
+  };
+};
+
+export type EntityPointerStatus = {
+  drag: boolean;
+  hover: boolean;
+  focus: boolean;
+};
+
+export const openOnEntityPointerStatus = (entity: Entity) => {
+  const status: EntityPointerStatus = {
+    drag: false,
+    hover: false,
+    focus: false,
+  };
+  const emti = debounce(() => {
+    entity.bus.emit("pointerStatus", status);
+  }, 16);
+  const handler = (partial: Partial<EntityPointerStatus>) => {
+    Object.assign(status, partial);
+    emti();
+  };
+  const focusHandler = () => handler({ focus: true });
+  const blurHandler = () => handler({ focus: false });
+  const hoverHandler = () => handler({ hover: true });
+  const leaveHandler = () => handler({ hover: false });
+  const dragHandler = () => handler({ drag: true });
+  const dropHandler = () => handler({ drag: false });
+
+  entity.bus.on("focus", focusHandler);
+  entity.bus.on("blur", blurHandler);
+  entity.bus.on("hover", hoverHandler);
+  entity.bus.on("leave", leaveHandler);
+  entity.bus.on("drag", dragHandler);
+  entity.bus.on("drop", dropHandler);
+
+  const destory = () => {
+    entity.bus.off("focus", focusHandler);
+    entity.bus.off("blur", blurHandler);
+    entity.bus.off("hover", hoverHandler);
+    entity.bus.off("leave", leaveHandler);
+    entity.bus.off("drag", dragHandler);
+    entity.bus.off("drop", dropHandler);
+  };
+
+  entity.bus.on("destroyBefore", destory);
+  return destory;
+};

+ 40 - 22
src/board/core/base/entity.ts

@@ -10,6 +10,8 @@ import {
   summarizeEntity,
   traversEntityTree,
   entityInit,
+  EntityPointerStatus,
+  openOnEntityPointerStatus,
 } from "./entity-server";
 import { Stage } from "konva/lib/Stage";
 import { Emitter } from "../type";
@@ -18,10 +20,10 @@ import {
   closeOnEntityTree,
   openOnEntityTree,
   TreeEvent,
-  EntityPointerStatus,
   TreeEventName,
-  openOnEntityPointerStatus,
 } from "../event";
+import { hasAutoEmitDataChange } from "./entity-root-server";
+import { getChangePart } from "../../shared";
 
 export type EntityTransmit = {
   root: Root;
@@ -37,14 +39,14 @@ export type EntityProps<T> = {
   teleport?: Group | Layer;
 };
 
-export type EntityEvent = {
+export type EntityEvent<T = any> = {
   createBefore: void;
   created: void;
   mountBefore: void;
   mounted: void;
   destroyBefore: void;
   destroyed: void;
-  updateAttrib: void;
+  updateAttrib: [T, T];
   pointerStatus: EntityPointerStatus;
   hover: void;
   leave: void;
@@ -112,7 +114,7 @@ export class Entity<
   shape: S;
   name: string;
   props: TP;
-  bus = mitt() as Emitter<EntityEvent>;
+  bus = mitt() as Emitter<EntityEvent<T>>;
 
   // tree
   root: TR["root"];
@@ -127,10 +129,6 @@ export class Entity<
     this.zIndex = props.zIndex || 0;
     this.key = props.key;
 
-    if (typeof this.key !== "string" && typeof this.key !== "number") {
-      console.log(this);
-      throw "entity 的key 必须为string | number";
-    }
     entityInit(this);
     openOnEntityPointerStatus(this);
   }
@@ -154,14 +152,6 @@ export class Entity<
     this.root.trigger && this.root.trigger.drop(this);
   }
 
-  openEvent(name: TreeEventName) {
-    openOnEntityTree(this, name);
-  }
-
-  closeEvent(name: TreeEventName) {
-    closeOnEntityTree(this, name);
-  }
-
   initShape(): S {
     return new Group() as S;
   }
@@ -207,11 +197,30 @@ export class Entity<
   }
 
   setAttrib(newAttrib: Partial<T>) {
-    const attribUpdated = this.attrib !== newAttrib;
+    if (typeof newAttrib === "object" && !Array.isArray(newAttrib)) {
+      const { addPort } = getChangePart(
+        Object.keys(this.attrib),
+        Object.keys(newAttrib)
+      );
+      for (const key of addPort) {
+        newAttrib[key] = this.attrib[key];
+      }
+    }
+    if (this.attrib !== newAttrib) {
+      this.bus.emit("updateAttrib", [newAttrib as T, this.attrib]);
+    }
     this.attrib = newAttrib as T;
     this.isMounted && this.diffRedraw();
-    attribUpdated && this.bus.emit("updateAttrib");
-    this.root && this.root.bus.emit("updateEntity", this);
+    if (this.root) {
+      this.root.bus.emit("setEntity", this);
+      if (
+        this.root.history &&
+        !this.root.history.hasRecovery &&
+        !hasAutoEmitDataChange(this.root)
+      ) {
+        this.root.bus.emit("entityChange", { setEntitys: [this] });
+      }
+    }
   }
 
   needReleases(): (() => void) | Array<() => void> {
@@ -222,7 +231,7 @@ export class Entity<
     const entitys = Array.isArray(entity) ? entity : [entity];
 
     for (const entity of entitys) {
-      if (entity.isMounted) {
+      if (entity.isMounted || this.isMounted) {
         mountEntityTree(this, entity);
       } else {
         setEntityParent(this, entity);
@@ -245,8 +254,17 @@ export class Entity<
   }
 
   destory() {
+    if (this.root) {
+      this.root.bus.emit("delEntity", this);
+      if (
+        this.root.history &&
+        !this.root.history.hasRecovery &&
+        !hasAutoEmitDataChange(this.root)
+      ) {
+        this.root.bus.emit("entityChange", { delEntitys: [this] });
+      }
+    }
     this.bus.emit("destroyBefore");
-    this.root && this.root.bus.emit("delEntity", this);
 
     while (this.children.length) {
       this.children[0].destory();

+ 0 - 48
src/board/core/event/entity-event.ts

@@ -1,48 +0,0 @@
-import { debounce } from "../../shared";
-import { Entity } from "../base/entity";
-
-export type EntityPointerStatus = {
-  drag: boolean;
-  hover: boolean;
-  focus: boolean;
-};
-
-export const openOnEntityPointerStatus = (entity: Entity) => {
-  const status: EntityPointerStatus = {
-    drag: false,
-    hover: false,
-    focus: false,
-  };
-  const emti = debounce(() => {
-    entity.bus.emit("pointerStatus", status);
-  }, 16);
-  const handler = (partial: Partial<EntityPointerStatus>) => {
-    Object.assign(status, partial);
-    emti();
-  };
-  const focusHandler = () => handler({ focus: true });
-  const blurHandler = () => handler({ focus: false });
-  const hoverHandler = () => handler({ hover: true });
-  const leaveHandler = () => handler({ hover: false });
-  const dragHandler = () => handler({ drag: true });
-  const dropHandler = () => handler({ drag: false });
-
-  entity.bus.on("focus", focusHandler);
-  entity.bus.on("blur", blurHandler);
-  entity.bus.on("hover", hoverHandler);
-  entity.bus.on("leave", leaveHandler);
-  entity.bus.on("drag", dragHandler);
-  entity.bus.on("drop", dropHandler);
-
-  const destory = () => {
-    entity.bus.off("focus", focusHandler);
-    entity.bus.off("blur", blurHandler);
-    entity.bus.off("hover", hoverHandler);
-    entity.bus.off("leave", leaveHandler);
-    entity.bus.off("drag", dragHandler);
-    entity.bus.off("drop", dropHandler);
-  };
-
-  entity.bus.on("destroyBefore", destory);
-  return destory;
-};

+ 0 - 123
src/board/core/event/entity-root-event.ts

@@ -1,123 +0,0 @@
-import { onEntity, openOnEntityTree, TreeEvent } from "./index";
-import { findEntityByShape } from "../base/entity-server";
-import { debounce, getChangePart, mergeFuns } from "../../shared";
-import { Root } from "../base/entity-root";
-import { Entity } from "../base/entity";
-
-export const injectPointerEvents = (root: Root) => {
-  const store = {
-    hovers: new Set<Entity>(),
-    focus: new Set<Entity>(),
-    drag: null as Entity,
-  };
-  const oldStore = {
-    hovers: [] as Entity[],
-    focus: [] as Entity[],
-    drag: null as Entity,
-  };
-
-  const emit = debounce(() => {
-    const hovers = [...store.hovers];
-    const focus = [...store.focus];
-    const hoverChange = getChangePart(hovers, oldStore.hovers);
-    const focusChange = getChangePart(focus, oldStore.focus);
-
-    hoverChange.addPort.forEach((entity) => entity.bus.emit("hover"));
-    hoverChange.delPort.forEach((entity) => entity.bus.emit("leave"));
-    focusChange.addPort.forEach((entity) => entity.bus.emit("focus"));
-    focusChange.delPort.forEach((entity) => entity.bus.emit("blur"));
-
-    if (oldStore.drag !== store.drag) {
-      oldStore.drag && oldStore.drag.bus.emit("drop");
-      store.drag && store.drag.bus.emit("drag");
-    }
-
-    oldStore.drag = store.drag;
-    oldStore.hovers = hovers;
-    oldStore.focus = focus;
-  }, 16);
-
-  const needReleases = [
-    openOnEntityTree(root, "mouseover"),
-    openOnEntityTree(root, "mouseout"),
-    openOnEntityTree(root, "click"),
-    openOnEntityTree(root, "touchend"),
-    onEntity(root, "dragstart", (ev) => {
-      const hit = findEntityByShape(root, ev.target);
-      store.drag = hit;
-      emit();
-    }),
-    onEntity(root, "dragend", () => {
-      store.drag = null;
-      emit();
-    }),
-  ];
-
-  const enterHandler = ({ paths }: TreeEvent) => {
-    paths.forEach((entity) => {
-      store.hovers.add(entity);
-    });
-    emit();
-  };
-  const leaveHandler = ({ paths }: TreeEvent) => {
-    paths.forEach((entity) => {
-      store.hovers.delete(entity);
-    });
-    emit();
-  };
-  const clickHandler = ({ paths }: TreeEvent) => {
-    store.focus.clear();
-    paths.forEach((entity) => {
-      store.focus.add(entity);
-    });
-    emit();
-  };
-
-  root.bus.on("mouseover" as any, enterHandler);
-  root.bus.on("mouseout" as any, leaveHandler);
-  root.bus.on("click", clickHandler);
-  root.bus.on("touchend", clickHandler);
-
-  const destory = () => {
-    mergeFuns(needReleases)();
-    root.bus.off("mouseover" as any, enterHandler);
-    root.bus.off("mouseout" as any, leaveHandler);
-    root.bus.off("click", clickHandler);
-    root.bus.off("touchend", clickHandler);
-  };
-
-  root.bus.on("destroyBefore", destory);
-  return {
-    focus(...entitys: Entity[]) {
-      store.focus.clear();
-      entitys.forEach((entity) => store.focus.add(entity));
-      emit();
-    },
-    blur(...entitys: Entity[]) {
-      entitys.forEach((entity) => store.focus.delete(entity));
-      emit();
-    },
-    hover(...entitys: Entity[]) {
-      store.hovers.clear();
-      entitys.forEach((entity) => store.hovers.add(entity));
-      emit();
-    },
-    leave(...entitys: Entity[]) {
-      entitys.forEach((entity) => store.hovers.delete(entity));
-      emit();
-    },
-    drag(entity: Entity) {
-      store.drag = entity;
-      emit();
-    },
-    drop(entity: Entity) {
-      if (store.drag === entity) {
-        store.drag = entity;
-        emit();
-      }
-    },
-    destory,
-  };
-};
-
-export type PointerEvents = ReturnType<typeof injectPointerEvents>;

+ 3 - 6
src/board/core/event/index.ts

@@ -25,9 +25,10 @@ export const onEntity = (
   event: string,
   cb: (ev: KonvaEventObject<any>) => void
 ) => {
-  const handler = (ev: KonvaEventObject<any>) =>
+  const handler = (ev: KonvaEventObject<any>) => {
     canEntityReply(entity) && cb(ev);
-  const shape = entity === entity.root ? entity.root.shape : entity.shape;
+  };
+  const shape = entity === entity.root ? entity.root.stage : entity.shape;
   shape.on(event, handler);
   const destory = () => shape.off(event, handler);
   entity.bus.on("destroyBefore", destory);
@@ -103,7 +104,6 @@ const entityTreeDistributor = (root: Root) => {
 
     shape.on(name, (ev: KonvaEventObject<any>) => {
       const self = findEntityByShape(root, ev.target);
-      // console.log(ev.target, self);
       self && emitEntityTree(self, name);
     });
   };
@@ -197,6 +197,3 @@ export const closeOnEntityTree = (entity: Entity, key?: string) => {
   const [name] = getEventArgs(key);
   distributor && distributor.off(entity, name);
 };
-
-export * from "./entity-root-event";
-export * from "./entity-event";

+ 58 - 0
src/board/core/helper/continuity-draw.ts

@@ -0,0 +1,58 @@
+import { Root } from "../base/entity-root";
+import { onEntity } from "../event";
+import { Pos } from "../type";
+import { getOffset } from "./dom";
+
+const watchStopContinuity = (
+  root: Root,
+  stop: () => void,
+  success?: () => void
+) => {
+  const mouseStop = (ev: Event) => {
+    stop();
+    ev.preventDefault();
+    success && success();
+    destory();
+  };
+
+  const keyStop = (ev: KeyboardEvent) => {
+    if (ev.key.toUpperCase() === "ESCAPE") {
+      stop();
+      success && success();
+      destory();
+    }
+  };
+
+  root.container.addEventListener("contextmenu", mouseStop);
+  document.documentElement.addEventListener("keyup", keyStop);
+  const destory = () => {
+    root.container.removeEventListener("contextmenu", mouseStop);
+    document.documentElement.removeEventListener("keyup", keyStop);
+  };
+
+  return destory;
+};
+
+export const continuityClick = (
+  root: Root,
+  continuity: (pos: Pos) => void,
+  success?: () => void
+) => {
+  root.editMode({ includeNews: true, only: true });
+  const cancelClick = onEntity(root, "click", (ev) => {
+    const pos = root.getReal(getOffset(ev.evt));
+    continuity(pos);
+  });
+
+  const cancelContinuity = () => {
+    cancelClick();
+    root.leaveEditMode();
+    console.log("stop");
+  };
+  const cancelWatch = watchStopContinuity(root, cancelContinuity, success);
+
+  return () => {
+    cancelContinuity();
+    cancelWatch();
+  };
+};

+ 136 - 0
src/board/core/helper/dom.ts

@@ -0,0 +1,136 @@
+import { mergeFuns } from "../../shared";
+import { getLineDist } from "../../shared/nmath";
+import { Pos } from "../type";
+
+export const getTouchOffset = (dom: HTMLElement, touch: Touch) => {
+  const rect = dom.getBoundingClientRect();
+  const offsetX = touch.pageX - rect.left;
+  const offsetY = touch.pageY - rect.top;
+
+  return {
+    x: offsetX,
+    y: offsetY,
+  };
+};
+
+export const getTouchScaleProps = (dom: HTMLElement, touches: Touch[]) => {
+  const start = getTouchOffset(dom, touches[0]);
+  const end = getTouchOffset(dom, touches[1]);
+  const center = {
+    x: (end.x + start.x) / 2,
+    y: (end.y + start.y) / 2,
+  };
+  const initDist = getLineDist(start, end);
+  return {
+    center,
+    dist: initDist,
+  };
+};
+
+export const getOffset = (ev: MouseEvent | TouchEvent) => {
+  if (ev instanceof TouchEvent) {
+    const dom = ev.target as HTMLElement;
+    return getTouchOffset(dom, ev.changedTouches[0]);
+  } else {
+    return {
+      x: ev.offsetX,
+      y: ev.offsetY,
+    };
+  }
+};
+
+export type UNPosition = {
+  left?: number;
+  top?: number;
+  right?: number;
+  bottom?: number;
+};
+export const getNorPosition = (
+  un: UNPosition,
+  size: { w: number; h: number },
+  selfSize: { w: number; h: number }
+) => {
+  const position = {} as Required<Pick<UNPosition, "left" | "top">>;
+
+  if (!("left" in un) && !("right" in un)) {
+    position.left = 0;
+  }
+  if (!("top" in un) && !("bottom" in un)) {
+    position.top = 0;
+  }
+
+  if ("left" in un && "top" in un) {
+    return { left: un.left, top: un.top };
+  }
+
+  if (!("left" in un)) {
+    position.left = size.w - un.right - selfSize.w;
+  }
+  if (!("top" in un)) {
+    position.top = size.h - un.bottom - selfSize.h;
+  }
+  return position;
+};
+
+export const touchScaleListener = (
+  dom: HTMLElement,
+  cb: (props: { center: Pos; scale: number }) => void
+) => {
+  const startHandler = (ev: TouchEvent) => {
+    if (ev.changedTouches.length <= 1) return;
+    let prevScale = getTouchScaleProps(ev.target as HTMLElement, [
+      ...ev.changedTouches,
+    ]);
+    ev.preventDefault();
+
+    const moveHandler = (ev: TouchEvent) => {
+      if (ev.changedTouches.length <= 1) return;
+      const curScale = getTouchScaleProps(ev.target as HTMLElement, [
+        ...ev.changedTouches,
+      ]);
+      cb({ center: prevScale.center, scale: curScale.dist / prevScale.dist });
+      prevScale = curScale;
+      ev.preventDefault();
+    };
+    const endHandler = (ev: TouchEvent) => {
+      document.documentElement.removeEventListener("touchmove", moveHandler);
+      document.documentElement.removeEventListener("touchend", endHandler);
+      ev.preventDefault();
+    };
+
+    document.documentElement.addEventListener("touchmove", moveHandler, {
+      passive: false,
+    });
+    document.documentElement.addEventListener("touchend", endHandler, {
+      passive: false,
+    });
+  };
+
+  dom.addEventListener("touchstart", startHandler, { passive: false });
+
+  return () => {
+    dom.removeEventListener("touchstart", startHandler);
+  };
+};
+
+export const whellListener = (
+  dom: HTMLElement,
+  cb: (props: { center: Pos; scale: number }) => void
+) => {
+  const whellHandler = (ev: WheelEvent) => {
+    const scale = 1 - ev.deltaY / 100;
+    const center = getOffset(ev);
+    cb({ center, scale });
+    ev.preventDefault();
+  };
+
+  dom.addEventListener("wheel", whellHandler);
+  return () => {
+    dom.removeEventListener("wheel", whellHandler);
+  };
+};
+
+export const scaleListener = (
+  dom: HTMLElement,
+  cb: (props: { center: Pos; scale: number }) => void
+) => mergeFuns(touchScaleListener(dom, cb), whellListener(dom, cb));

+ 0 - 52
src/board/core/helper/entity-drag.ts

@@ -1,52 +0,0 @@
-import { KonvaEventObject } from "konva/lib/Node";
-import { Entity } from "../base/entity";
-import { canEntityReply } from "../event";
-import { Pos } from "../type";
-
-export type DragHandlers = (ev: KonvaEventObject<any>) => {
-  move: (move: Pos, ev: KonvaEventObject<any>) => void;
-  end?: (ev: KonvaEventObject<any>) => void;
-};
-
-export const openEntityDrag = <T extends Entity>(
-  entity: T,
-  getHandlers: DragHandlers
-) => {
-  entity.shape.draggable(true);
-
-  let canReply = false;
-  let moveHandler: ReturnType<DragHandlers>["move"];
-  let endHandler: ReturnType<DragHandlers>["end"];
-  let resolve: () => void;
-
-  entity.shape.on("dragstart.drag", (ev) => {
-    const promise = new Promise<void>((r) => (resolve = r));
-    entity.root.editMode(() => promise, { only: true, entitys: [entity] });
-
-    canReply = canEntityReply(entity);
-    if (canReply) {
-      const handlers = getHandlers(ev);
-      moveHandler = handlers.move;
-      endHandler = handlers.end;
-    }
-  });
-
-  entity.shape.dragBoundFunc((pos, ev) => {
-    if (canReply) {
-      moveHandler(entity.root.invMat.point(pos), ev);
-    }
-    return entity.shape.absolutePosition();
-  });
-
-  entity.shape.on("dragend.drag", (ev) => {
-    moveHandler = null;
-    canReply = false;
-    endHandler && endHandler(ev);
-    resolve && resolve();
-  });
-
-  return () => {
-    entity.shape.draggable(false);
-    entity.shape.off("dragstart.drag dragend.drag");
-  };
-};

+ 0 - 1
src/board/core/helper/svg.ts

@@ -47,7 +47,6 @@ export const pathsToShapeAct = (
   );
 
   const common = () => {
-    console.log(svg.paths);
     paths.forEach((path, ndx) => {
       const attrib = svg.paths[ndx];
       attrib.fill && path.fill(attrib.fill);

+ 11 - 0
src/board/core/index.ts

@@ -0,0 +1,11 @@
+export * from "./base/entity";
+export * from "./base/entity-factory";
+export * from "./base/entity-group";
+export * from "./base/entity-map";
+export * from "./base/entity-root";
+
+export { canEntityReply, onEntity, emitEntityTree } from "./event/index";
+
+export * from "./helper/shape";
+export * from "./helper/svg";
+export * from "./helper/continuity-draw";

+ 1 - 1
src/board/plugins/camera-plugin.ts

@@ -82,7 +82,7 @@ export class CameraQueryPlugin {
       const scale = 1 - ev.deltaY / 1000;
 
       const center = new Vector3(ev.offsetX, ev.offsetY, 0).applyMatrix4(
-        this.clipMat.clone().multiply(this.cameraMat).invert()
+        this.clipMat.clone().multiply(this.cameraMat)
       );
       this.scale([center.x, center.y], scale);
     };

+ 60 - 0
src/board/shared/math.ts

@@ -400,3 +400,63 @@ export const getLineInnerContinuityPoints = (
   }
   return points;
 };
+
+// 创建对齐端口矩阵
+export const alignPort = (
+  target: number[],
+  origin: number[],
+  retainScale = false,
+  padding: number[] | number = [0, 0]
+) => {
+  padding = !Array.isArray(padding) ? [padding, padding] : padding;
+
+  const realWidth = origin[2] - origin[0];
+  const realHeight = origin[3] - origin[1];
+  const screenWidth = target[2] - target[0];
+  const screenHeight = target[3] - target[1];
+  const effectiveWidth = screenWidth - padding[0] * 2;
+  const effectiveHeight = screenHeight - padding[1] * 2;
+
+  // 计算缩放比例
+  let scaleX = effectiveWidth / realWidth;
+  let scaleY = effectiveHeight / realHeight;
+
+  if (retainScale) {
+    const scale = Math.min(scaleX, scaleY); // 选择较小的比例以保持内容比例
+    scaleX = scale;
+    scaleY = scale;
+  }
+
+  const offsetX = (screenWidth - realWidth * scaleX) / 2 - origin[0] * scaleX;
+  const offsetY = (screenHeight - realHeight * scaleY) / 2 - origin[1] * scaleY;
+
+  return {
+    scale: { x: scaleX, y: scaleY },
+    translation: { x: offsetX, y: offsetY },
+  };
+};
+
+export const decomposeMat3 = (matrix: Matrix3) => {
+  const elements = matrix.elements;
+
+  // 提取平移
+  const tx = elements[6];
+  const ty = elements[7];
+
+  // 提取缩放
+  const scaleX = Math.sqrt(
+    elements[0] * elements[0] + elements[3] * elements[3]
+  );
+  const scaleY = Math.sqrt(
+    elements[1] * elements[1] + elements[4] * elements[4]
+  );
+
+  // 提取旋转(假设没有倾斜)
+  const rotation = Math.atan2(elements[3], elements[0]);
+
+  return {
+    translation: { x: tx, y: ty },
+    scale: { x: scaleX, y: scaleY },
+    rotation: rotation, // 以弧度为单位
+  };
+};

+ 12 - 0
src/board/shared/nmath.ts

@@ -0,0 +1,12 @@
+import { Vector2 } from "three";
+import { Pos } from "../core/type";
+
+/**
+ * 获取两点距离
+ * @param p1 点1
+ * @param p2 点2
+ * @returns 距离
+ */
+export const getLineDist = (p1: Pos, p2: Pos) => {
+  return new Vector2(p1.x, p1.y).distanceTo(p2);
+};

+ 3 - 0
src/board/shared/public.ts

@@ -65,3 +65,6 @@ const _inRevise = (raw1, raw2, readly: Set<[any, any]>) => {
 };
 
 export const inRevise = (raw1, raw2) => _inRevise(raw1, raw2, new Set());
+
+export const asyncTimeout = (mis?: number) =>
+  new Promise((resolive) => setTimeout(resolive, mis));