Prechádzať zdrojové kódy

✨ feat(组态编辑器): 新增画布拖拽模式并优化编辑状态管理

- 增加选择与拖拽模式切换,统一左键、右键、快捷键及锁定状态下的画布平移判定
- 将缩放、平移和编辑辅助状态从撤销历史中分离,保留组件及画布内容配置的历史记录
- 文档快照忽略缩放中心与画布偏移,避免单纯浏览画布触发未保存提示
- 修正组件拖动完成时序,确保最终坐标写回后再记录历史快照
- 优化背景颜色与背景图片控件的预览、删除及暗色模式样式
- 补充画布拖拽、文档快照、移动完成和历史边界测试
Zimo 1 týždeň pred
rodič
commit
7ed88beb7b

+ 8 - 3
src/components/mt-dzr/index.vue

@@ -61,6 +61,7 @@ import RotateHandle from './components/rotate-handle.vue'
 import renderItem from './components/render-item.vue'
 import { dzrStore } from './store/index'
 import type { MouseTouchEvent } from './utils/types'
+import { completeModelMove } from './utils/move-completion'
 const MtDzrProps = withDefaults(defineProps<IDzrProps>(), {
   id: randomString(16),
   modelValue: () => {
@@ -232,12 +233,16 @@ const onMouseDown = (de: MouseTouchEvent) => {
   autoDestroyMouseMove(onMouseMove, () => {
     nextTick(() => {
       dzrStore.hideDzrCopy()
-      MtDzrEmits('moveMouseUp')
-      MtDzrEmits('update:modelValue', {
+      const finalPosition = {
         ...MtDzrProps.modelValue,
         left: set_new_left,
         top: set_new_top
-      })
+      }
+      completeModelMove(
+        finalPosition,
+        (value) => MtDzrEmits('update:modelValue', value),
+        () => MtDzrEmits('moveMouseUp')
+      )
     })
   })
 }

+ 8 - 0
src/components/mt-dzr/utils/move-completion.ts

@@ -0,0 +1,8 @@
+export const completeModelMove = <T>(
+  value: T,
+  updateModel: (value: T) => void,
+  notifyMoveCompleted: () => void
+) => {
+  updateModel(value)
+  notifyMoveCompleted()
+}

+ 97 - 23
src/components/mt-edit/components/layout/main-panel/index.vue

@@ -1,5 +1,12 @@
 <template>
-  <div ref="viewportRef" class="main-panel overflow-hidden w-1/1 h-1/1" @wheel="onMouseWheel">
+  <div
+    ref="viewportRef"
+    class="main-panel overflow-hidden w-1/1 h-1/1"
+    :class="{
+      'main-panel--pan-mode': panModeEnabled,
+      'main-panel--panning': ['beginDragCanvas', 'runDragCanvas'].includes(globalStore.intention)
+    }"
+    @wheel="onMouseWheel">
     <div
       id="mtCanvasArea"
       ref="canvasAreaRef"
@@ -69,7 +76,35 @@
       :menu-info="contextMenuStore.menuInfo"
       :show="globalStore.intention == 'showContextMenu'"
       @on-context-menu-click="onContextMenuClick" />
-    <div class="zoom-toolbar" role="toolbar" aria-label="画布缩放" @mousedown.stop @click.stop>
+    <div
+      class="zoom-toolbar"
+      role="toolbar"
+      aria-label="画布操作与缩放"
+      @mousedown.stop
+      @click.stop>
+      <div class="zoom-toolbar__modes" aria-label="画布操作模式">
+        <el-tooltip content="选择组件或框选" placement="top">
+          <el-button
+            class="zoom-toolbar__icon zoom-toolbar__mode"
+            :class="{ 'is-active': !panModeEnabled }"
+            text
+            aria-label="切换到选择模式"
+            @click="panModeEnabled = false">
+            <el-icon><Pointer /></el-icon>
+          </el-button>
+        </el-tooltip>
+        <el-tooltip content="按住左键拖动画布" placement="top">
+          <el-button
+            class="zoom-toolbar__icon zoom-toolbar__mode"
+            :class="{ 'is-active': panModeEnabled }"
+            text
+            aria-label="切换到画布拖拽模式"
+            @click="panModeEnabled = true">
+            <el-icon><Rank /></el-icon>
+          </el-button>
+        </el-tooltip>
+      </div>
+      <span class="zoom-toolbar__divider"></span>
       <el-tooltip content="缩小 10%" placement="top">
         <el-button
           class="zoom-toolbar__icon"
@@ -143,7 +178,7 @@ import ContextMenu from '@/components/mt-edit/components/context-menu/index.vue'
 import { contextMenuStore } from '@/components/mt-edit/store/context-menu'
 import DrawLineRender from '@/components/mt-edit/components/draw-line-render/index.vue'
 import { configStore } from '@/components/mt-edit/store/config'
-import { FullScreen, Minus, Plus } from '@element-plus/icons-vue'
+import { FullScreen, Minus, Plus, Pointer, Rank } from '@element-plus/icons-vue'
 import {
   calculateFitCanvas,
   MAX_CANVAS_SCALE,
@@ -152,6 +187,7 @@ import {
   stepCanvasScale,
   type CanvasScaleDirection
 } from '@/components/mt-edit/utils/canvas-scale'
+import { shouldStartCanvasPan } from '@/components/mt-edit/utils/canvas-pan'
 type MainPanelProps = {
   groupEnabled: boolean
   unGroupEnabled: boolean
@@ -167,6 +203,7 @@ const selectedAreaRef = ref<InstanceType<typeof SelectedArea>>()
 const dragCanvasRef = ref<InstanceType<typeof DragCanvas>>()
 const dragLineRenderRef = ref<InstanceType<typeof DrawLineRender>>()
 const shortcut_drag_select_item_id = ref('')
+const panModeEnabled = ref(false)
 // 是否需要重新计算画布缩放中心点
 const is_need_recal_center = ref(true)
 let is_listen_keydown = false // 是否已经监听了键盘事件
@@ -336,8 +373,19 @@ const onDragOver = (e: DragEvent) => {
 const onRenderCoreMouseDown = (item: IDoneJson, e: MouseEvent) => {
   commitActiveRightAsideEditor()
   beginListenerKeyDown()
-  if (isSystemShortcutKey(e)) {
-    shortcut_drag_select_item_id.value = item.lock ? '' : item.id
+  const systemShortcutPressed = isSystemShortcutKey(e)
+  if (
+    shouldStartCanvasPan({
+      button: e.button,
+      panModeEnabled: panModeEnabled.value,
+      systemShortcutPressed,
+      canvasLocked: globalStore.lock
+    })
+  ) {
+    shortcut_drag_select_item_id.value =
+      systemShortcutPressed && !panModeEnabled.value && !globalStore.lock && !item.lock
+        ? item.id
+        : ''
     beginDragCanvas(e)
     return
   }
@@ -384,17 +432,19 @@ const onRenderCoreMouseDown = (item: IDoneJson, e: MouseEvent) => {
 const onMouseDown = (e: MouseEvent) => {
   commitActiveRightAsideEditor()
   beginListenerKeyDown()
-  if (isSystemShortcutKey(e)) {
+  if (
+    shouldStartCanvasPan({
+      button: e.button,
+      panModeEnabled: panModeEnabled.value,
+      systemShortcutPressed: isSystemShortcutKey(e),
+      canvasLocked: globalStore.lock
+    })
+  ) {
     shortcut_drag_select_item_id.value = ''
     beginDragCanvas(e)
     return
   }
   globalStore.cancelAllSelect()
-  // 锁定状态或者右键点击进行画布拖动
-  if (globalStore.lock || e.button == 2) {
-    beginDragCanvas(e)
-    return
-  }
   if (mainPanelProps.lineAppendEnable) {
     globalStore.setIntention('drawSysLineStart')
     const { realityX, realityY } = getRealityXY(e, canvasAreaRef.value?.getBoundingClientRect())
@@ -833,7 +883,6 @@ const dragCanvasMouseMove = (move_x: number, move_y: number) => {
 const dragCanvasMouseUp = () => {
   if (globalStore.intention == 'runDragCanvas') {
     globalStore.setIntention('endDragCanvas')
-    cacheStore.addHistory()
   } else {
     if (shortcut_drag_select_item_id.value) {
       const find_item = globalStore.done_json.find(
@@ -1231,17 +1280,12 @@ const onKeydown = (e: KeyboardEvent) => {
     cacheStore.addHistory()
   }
 }
-const updateCanvasScale = (scale: number, debounced = false) => {
+const updateCanvasScale = (scale: number) => {
   const nextScale = normalizeCanvasScale(scale)
   if (nextScale === globalStore.canvasCfg.scale) {
     return false
   }
   globalStore.canvasCfg.scale = nextScale
-  if (debounced) {
-    cacheStore.addHistoryDebounced('canvas-scale')
-  } else {
-    cacheStore.addHistory()
-  }
   return true
 }
 const zoomCanvas = (direction: CanvasScaleDirection) => {
@@ -1273,7 +1317,6 @@ const fitCanvas = () => {
   globalStore.canvasCfg.scale = fitResult.scale
   globalStore.canvasCfg.transform_origin = fitResult.transformOrigin
   globalStore.canvasCfg.drag_offset = fitResult.dragOffset
-  cacheStore.addHistory()
 }
 const onMouseWheel = (e: WheelEvent) => {
   if (isSystemShortcutKey(e)) {
@@ -1283,10 +1326,7 @@ const onMouseWheel = (e: WheelEvent) => {
     if (!direction) {
       return
     }
-    const didScale = updateCanvasScale(
-      stepCanvasScale(globalStore.canvasCfg.scale, direction),
-      true
-    )
+    const didScale = updateCanvasScale(stepCanvasScale(globalStore.canvasCfg.scale, direction))
     if (!didScale) {
       return
     }
@@ -1360,6 +1400,16 @@ defineExpose({
   position: relative;
 }
 
+.main-panel--pan-mode :deep(.canvasArea),
+.main-panel--pan-mode :deep(.canvasArea *) {
+  cursor: grab !important;
+}
+
+.main-panel--panning :deep(.canvasArea),
+.main-panel--panning :deep(.canvasArea *) {
+  cursor: grabbing !important;
+}
+
 .canvasArea {
   position: relative;
   top: v-bind('globalStore.canvasCfg.drag_offset.y + "px"');
@@ -1405,6 +1455,19 @@ defineExpose({
   padding: 0;
 }
 
+.zoom-toolbar__modes {
+  display: flex;
+  padding: 2px;
+  background: #f2f4f7;
+  border-radius: 8px;
+}
+
+.zoom-toolbar__mode.is-active {
+  color: #fff;
+  background: var(--el-color-primary);
+  box-shadow: 0 2px 5px rgb(64 158 255 / 28%);
+}
+
 .zoom-toolbar__icon:hover,
 .zoom-toolbar__text:hover,
 .zoom-toolbar__fit:hover {
@@ -1412,6 +1475,17 @@ defineExpose({
   background: var(--el-color-primary-light-9);
 }
 
+.zoom-toolbar__mode {
+  width: 30px;
+  min-width: 30px;
+  padding: 0;
+}
+
+.zoom-toolbar__mode.is-active:hover {
+  color: #fff;
+  background: var(--el-color-primary);
+}
+
 .zoom-toolbar__value {
   min-width: 52px;
   font-size: 12px;

+ 6 - 3
src/components/mt-edit/components/layout/right-aside/index.vue

@@ -35,7 +35,7 @@
   </div>
 </template>
 <script setup lang="ts">
-import { cacheStore } from '@/components/mt-edit/store/cache'
+import { cacheStore, getEditorHistoryCanvasCfg } from '@/components/mt-edit/store/cache'
 import { globalStore } from '@/components/mt-edit/store/global'
 import type {
   IDoneJson,
@@ -71,12 +71,15 @@ const onAddHistoryDebounced = () => {
   cacheStore.addHistoryDebounced(`item-props:${selected_item_id.value}`)
 }
 const onUpdateCanvasCfg = (canvasCfg: IGlobalStoreCanvasCfg) => {
+  const currentHistoryCanvasCfg = getEditorHistoryCanvasCfg(globalStore.canvasCfg)
+  const nextHistoryCanvasCfg = getEditorHistoryCanvasCfg(canvasCfg)
   globalStore.canvasCfg = canvasCfg
-  cacheStore.addHistoryDebounced('page-setting')
+  if (JSON.stringify(currentHistoryCanvasCfg) !== JSON.stringify(nextHistoryCanvasCfg)) {
+    cacheStore.addHistoryDebounced('page-setting')
+  }
 }
 const onUpdateGridCfg = (gridCfg: IGlobalStoreGridCfg) => {
   globalStore.gridCfg = gridCfg
-  cacheStore.addHistoryDebounced('page-setting')
 }
 const hasDeviceBindSlot = computed(() => {
   return !!slots.deviceBind

+ 76 - 12
src/components/mt-edit/components/layout/right-aside/page-setting.vue

@@ -57,12 +57,12 @@
           </el-select>
         </el-form-item>
         <el-form-item label="背景颜色" size="small">
-          <el-color-picker v-model="canvas_bg_color" />
+          <el-color-picker v-model="canvas_bg_color" class="canvas-background-color" />
         </el-form-item>
         <el-form-item label="背景图片" size="small">
           <el-upload
             ref="canvasBgImgUploadRef"
-            class="w-24px h-24px"
+            class="canvas-background-upload"
             v-model:file-list="bg_img_list"
             :auto-upload="false"
             :limit="1"
@@ -71,21 +71,19 @@
             accept="image/*"
             @mouseenter="show_clear_bg_img = true"
             @mouseleave="show_clear_bg_img = false">
-            <div class="flex justify-center items-center relative">
+            <div class="canvas-background-upload__trigger">
               <img
-                class="w-40px h-40px absolute left-0"
                 v-if="rightAsideProps.canvasCfg.img"
+                class="canvas-background-upload__preview"
                 :src="rightAsideProps.canvasCfg.img" />
-              <el-button v-else size="small" class="w-40px h-40px absolute left-0">
-                <el-icon title="上传" :size="20">
-                  <svg-analysis name="upload" />
-                </el-icon>
-              </el-button>
+              <el-icon v-else title="上传背景图片" :size="17">
+                <svg-analysis name="upload" />
+              </el-icon>
               <div
                 v-if="rightAsideProps.canvasCfg.img && show_clear_bg_img"
-                class="absolute w-40px h-40px left-0 opacity-80 bg-light-300 flex justify-center items-center"
+                class="canvas-background-upload__remove"
                 @click.stop="clearBgImg">
-                <el-icon title="删除" :size="25">
+                <el-icon title="删除背景图片" :size="16">
                   <svg-analysis name="delete" />
                 </el-icon>
               </div>
@@ -137,7 +135,6 @@ import {
   ElUpload,
   ElIcon,
   type UploadUserFile,
-  ElButton,
   type UploadFile,
   ElMessage
 } from 'element-plus'
@@ -421,6 +418,67 @@ const canvas_guide = computed({
   width: 100%;
 }
 
+.canvas-background-color,
+.canvas-background-color :deep(.el-color-picker__trigger) {
+  width: 32px;
+  height: 32px;
+}
+
+.canvas-background-color :deep(.el-color-picker__trigger) {
+  border-radius: 5px;
+}
+
+.canvas-background-upload {
+  width: 32px;
+  height: 32px;
+}
+
+.canvas-background-upload :deep(.el-upload) {
+  display: block;
+  width: 32px;
+  height: 32px;
+}
+
+.canvas-background-upload__trigger {
+  position: relative;
+  display: flex;
+  width: 32px;
+  height: 32px;
+  overflow: hidden;
+  color: #667085;
+  cursor: pointer;
+  background: #fff;
+  border: 1px solid #d0d5dd;
+  border-radius: 5px;
+  box-sizing: border-box;
+  transition:
+    border-color 0.2s,
+    color 0.2s;
+  align-items: center;
+  justify-content: center;
+}
+
+.canvas-background-upload__trigger:hover {
+  color: var(--el-color-primary);
+  border-color: var(--el-color-primary);
+}
+
+.canvas-background-upload__preview {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.canvas-background-upload__remove {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  color: #fff;
+  background: rgb(15 23 42 / 58%);
+  align-items: center;
+  justify-content: center;
+}
+
 .dark .setting-card {
   background: #202735;
   border-color: #303949;
@@ -433,4 +491,10 @@ const canvas_guide = computed({
 .dark .setting-card__header h3 {
   color: #dbe3ed;
 }
+
+.dark .canvas-background-upload__trigger {
+  color: #98a2b3;
+  background: #202735;
+  border-color: #475467;
+}
 </style>

+ 3 - 2
src/components/mt-edit/index.vue

@@ -133,6 +133,7 @@ import ImportJson from '@/components/mt-edit/components/import-json/index.vue'
 import { objectDeepClone } from './utils'
 import { genExportJson, useExportJsonToDoneJson } from './composables'
 import type { IExportJson } from './components/types'
+import { serializeEditorDocument } from './utils/editor-document'
 type MtEditProps = {
   title?: string
   useThumbnail?: boolean
@@ -201,7 +202,7 @@ const createExportJson = () => {
   return exportJson
 }
 const getExportJson = () => objectDeepClone<IExportJson>(createExportJson())
-const current_snapshot = computed(() => JSON.stringify(createExportJson()))
+const current_snapshot = computed(() => serializeEditorDocument(createExportJson()))
 const is_unsaved = computed(
   () => saved_snapshot.value !== '' && current_snapshot.value !== saved_snapshot.value
 )
@@ -335,7 +336,7 @@ const setImportJson = (exportJson: IExportJson) => {
   return true
 }
 const markSaved = (exportJson: IExportJson = getExportJson()) => {
-  saved_snapshot.value = JSON.stringify(exportJson)
+  saved_snapshot.value = serializeEditorDocument(exportJson)
 }
 const hasUnsavedChanges = () => is_unsaved.value
 const commitHistory = () => cacheStore.addHistory()

+ 39 - 10
src/components/mt-edit/store/__tests__/cache.spec.ts

@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { cacheStore } from '../cache'
 import {
   createDefaultCanvasCfg,
-  createDefaultGridCfg,
   globalStore,
   resetGlobalStore
 } from '../global'
@@ -56,7 +55,7 @@ describe('cacheStore 历史事务', () => {
     vi.useRealTimers()
   })
 
-  it('把属性、事件、设备绑定、画布和网格作为一个事务撤销和重做', () => {
+  it('把属性、事件、设备绑定和画布内容作为一个事务撤销和重做', () => {
     globalStore.done_json[0].props.text.val = '修改后'
     globalStore.done_json[0].binfo.left = 88
     globalStore.done_json[0].events.push({
@@ -76,9 +75,6 @@ describe('cacheStore 历史事务', () => {
       }
     ]
     globalStore.canvasCfg.color = '#101828'
-    globalStore.canvasCfg.drag_offset = { x: 35, y: 48 }
-    globalStore.gridCfg.enabled = false
-    globalStore.gridCfg.size = 24
     cacheStore.addHistory()
 
     expect(cacheStore.undo()).toBe(true)
@@ -87,7 +83,6 @@ describe('cacheStore 历史事务', () => {
     expect(globalStore.done_json[0].events).toEqual([])
     expect(globalStore.done_json[0].deviceBinds).toBeUndefined()
     expect(globalStore.canvasCfg).toEqual(createDefaultCanvasCfg())
-    expect(globalStore.gridCfg).toEqual(createDefaultGridCfg())
 
     expect(cacheStore.redo()).toBe(true)
     expect(textValue()).toBe('修改后')
@@ -95,8 +90,6 @@ describe('cacheStore 历史事务', () => {
     expect(globalStore.done_json[0].events).toHaveLength(1)
     expect(globalStore.done_json[0].deviceBinds).toHaveLength(1)
     expect(globalStore.canvasCfg.color).toBe('#101828')
-    expect(globalStore.canvasCfg.drag_offset).toEqual({ x: 35, y: 48 })
-    expect(globalStore.gridCfg).toMatchObject({ enabled: false, size: 24 })
   })
 
   it('把同一输入项的连续修改合并成一条历史', () => {
@@ -116,6 +109,42 @@ describe('cacheStore 历史事务', () => {
     expect(textValue()).toBe('初始文本')
   })
 
+  it('不记录画布缩放、平移和编辑辅助状态,也不会通过撤销改变它们', () => {
+    globalStore.canvasCfg.scale = 1.6
+    globalStore.canvasCfg.transform_origin = { x: 240, y: 180 }
+    globalStore.canvasCfg.drag_offset = { x: 80, y: 60 }
+    globalStore.canvasCfg.guide = false
+    globalStore.canvasCfg.adsorp = false
+    globalStore.canvasCfg.adsorp_diff = 20
+    globalStore.gridCfg = { enabled: false, align: false, size: 24 }
+    cacheStore.addHistory()
+
+    expect(cacheStore.history).toHaveLength(1)
+    expect(cacheStore.undo()).toBe(false)
+    expect(globalStore.canvasCfg.scale).toBe(1.6)
+    expect(globalStore.canvasCfg.transform_origin).toEqual({ x: 240, y: 180 })
+    expect(globalStore.canvasCfg.drag_offset).toEqual({ x: 80, y: 60 })
+    expect(globalStore.canvasCfg).toMatchObject({ guide: false, adsorp: false, adsorp_diff: 20 })
+    expect(globalStore.gridCfg).toEqual({ enabled: false, align: false, size: 24 })
+  })
+
+  it('撤销内容修改时保留当前画布视图和编辑辅助状态', () => {
+    globalStore.done_json[0].props.text.val = '修改后'
+    cacheStore.addHistory()
+
+    globalStore.canvasCfg.scale = 2
+    globalStore.canvasCfg.drag_offset = { x: 100, y: 120 }
+    globalStore.canvasCfg.guide = false
+    globalStore.gridCfg.enabled = false
+
+    expect(cacheStore.undo()).toBe(true)
+    expect(textValue()).toBe('初始文本')
+    expect(globalStore.canvasCfg.scale).toBe(2)
+    expect(globalStore.canvasCfg.drag_offset).toEqual({ x: 100, y: 120 })
+    expect(globalStore.canvasCfg.guide).toBe(false)
+    expect(globalStore.gridCfg.enabled).toBe(false)
+  })
+
   it('在防抖尚未结束时执行撤销也不会丢失当前修改', () => {
     globalStore.done_json[0].props.text.val = '尚未到期的修改'
     cacheStore.addHistoryDebounced('property:node-1:text', 350)
@@ -159,13 +188,13 @@ describe('cacheStore 历史事务', () => {
     expect(cacheStore.historyLimit).toBe(50)
 
     for (let size = 11; size <= 70; size += 1) {
-      globalStore.gridCfg.size = size
+      globalStore.done_json[0].binfo.width = size
       cacheStore.addHistory()
     }
 
     expect(cacheStore.history).toHaveLength(50)
     expect(cacheStore.historyIndex).toBe(49)
-    expect(cacheStore.history[0].gridCfg.size).toBe(21)
+    expect(cacheStore.history[0].doneJson[0].binfo.width).toBe(21)
 
     cacheStore.setHistoryLimit(1000)
     expect(cacheStore.historyLimit).toBe(100)

+ 16 - 6
src/components/mt-edit/store/cache.ts

@@ -3,9 +3,9 @@ import type {
   CacheBoundingBox,
   ICache,
   IDoneJson,
+  IEditorHistoryCanvasCfg,
   IEditorHistorySnapshot,
-  IGlobalStoreCanvasCfg,
-  IGlobalStoreGridCfg
+  IGlobalStoreCanvasCfg
 } from './types'
 import { objectDeepClone } from '../utils'
 import { globalStore } from './global'
@@ -13,6 +13,15 @@ import { globalStore } from './global'
 const DEFAULT_HISTORY_LIMIT = 80
 const pendingHistoryTimers = new Map<string, ReturnType<typeof setTimeout>>()
 
+export const getEditorHistoryCanvasCfg = (
+  canvasCfg: IGlobalStoreCanvasCfg
+): IEditorHistoryCanvasCfg => ({
+  width: canvasCfg.width,
+  height: canvasCfg.height,
+  color: canvasCfg.color,
+  img: canvasCfg.img
+})
+
 const createHistorySnapshot = (): IEditorHistorySnapshot => {
   const doneJson = objectDeepClone<IDoneJson[]>(globalStore.done_json)
   const clearActiveState = (items: IDoneJson[]) => {
@@ -24,16 +33,17 @@ const createHistorySnapshot = (): IEditorHistorySnapshot => {
   clearActiveState(doneJson)
   return {
     doneJson,
-    canvasCfg: objectDeepClone<IGlobalStoreCanvasCfg>(globalStore.canvasCfg),
-    gridCfg: objectDeepClone<IGlobalStoreGridCfg>(globalStore.gridCfg)
+    canvasCfg: getEditorHistoryCanvasCfg(globalStore.canvasCfg)
   }
 }
 
 const applyHistorySnapshot = (snapshot: IEditorHistorySnapshot) => {
   const nextSnapshot = objectDeepClone<IEditorHistorySnapshot>(snapshot)
   globalStore.setGlobalStoreDoneJson(nextSnapshot.doneJson)
-  globalStore.canvasCfg = nextSnapshot.canvasCfg
-  globalStore.gridCfg = nextSnapshot.gridCfg
+  globalStore.canvasCfg = {
+    ...globalStore.canvasCfg,
+    ...nextSnapshot.canvasCfg
+  }
   globalStore.setSelectItems([])
 }
 

+ 11 - 2
src/components/mt-edit/store/types.ts

@@ -234,10 +234,19 @@ export interface ILeftAside {
   registerConfig: (title: string, config: ILeftAsideConfigItemPublic[]) => void
 }
 // 缓存配置
+export type IEditorDocumentCanvasCfg = Omit<
+  IGlobalStoreCanvasCfg,
+  'scale' | 'transform_origin' | 'drag_offset'
+>
+
+export type IEditorHistoryCanvasCfg = Pick<
+  IGlobalStoreCanvasCfg,
+  'width' | 'height' | 'color' | 'img'
+>
+
 export interface IEditorHistorySnapshot {
   doneJson: IDoneJson[]
-  canvasCfg: IGlobalStoreCanvasCfg
-  gridCfg: IGlobalStoreGridCfg
+  canvasCfg: IEditorHistoryCanvasCfg
 }
 
 export interface ICache {

+ 39 - 0
src/components/mt-edit/utils/__tests__/canvas-pan.spec.ts

@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest'
+import { shouldStartCanvasPan } from '../canvas-pan'
+
+describe('画布拖拽手势', () => {
+  it('在拖拽模式下支持左键从画布或组件上开始拖动', () => {
+    expect(
+      shouldStartCanvasPan({
+        button: 0,
+        panModeEnabled: true,
+        systemShortcutPressed: false,
+        canvasLocked: false
+      })
+    ).toBe(true)
+  })
+
+  it('保留原有快捷键、右键和锁定状态拖拽', () => {
+    const baseGesture = {
+      panModeEnabled: false,
+      systemShortcutPressed: false,
+      canvasLocked: false
+    }
+
+    expect(shouldStartCanvasPan({ ...baseGesture, button: 2 })).toBe(true)
+    expect(shouldStartCanvasPan({ ...baseGesture, button: 0, systemShortcutPressed: true })).toBe(
+      true
+    )
+  })
+
+  it('普通左键仍然交给组件选择或画布框选', () => {
+    expect(
+      shouldStartCanvasPan({
+        button: 0,
+        panModeEnabled: false,
+        systemShortcutPressed: false,
+        canvasLocked: false
+      })
+    ).toBe(false)
+  })
+})

+ 30 - 0
src/components/mt-edit/utils/__tests__/editor-document.spec.ts

@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest'
+import { serializeEditorDocument } from '../editor-document'
+import { createDefaultCanvasCfg, createDefaultGridCfg } from '../../store/global'
+import type { IExportJson } from '../../components/types'
+
+const createExportJson = (): IExportJson => ({
+  canvasCfg: createDefaultCanvasCfg(),
+  gridCfg: createDefaultGridCfg(),
+  json: []
+})
+
+describe('编辑器文档快照', () => {
+  it('忽略缩放、缩放中心和画布平移等视图状态', () => {
+    const saved = createExportJson()
+    const viewed = createExportJson()
+    viewed.canvasCfg.scale = 2
+    viewed.canvasCfg.transform_origin = { x: 300, y: 200 }
+    viewed.canvasCfg.drag_offset = { x: 120, y: 90 }
+
+    expect(serializeEditorDocument(viewed)).toBe(serializeEditorDocument(saved))
+  })
+
+  it('仍然识别画布内容配置变化', () => {
+    const saved = createExportJson()
+    const changed = createExportJson()
+    changed.canvasCfg.color = '#101828'
+
+    expect(serializeEditorDocument(changed)).not.toBe(serializeEditorDocument(saved))
+  })
+})

+ 22 - 0
src/components/mt-edit/utils/__tests__/move-completion.spec.ts

@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import { completeModelMove } from '@/components/mt-dzr/utils/move-completion'
+
+describe('元素拖动完成时序', () => {
+  it('先写回最终坐标,再通知上层记录历史', () => {
+    let currentPosition = { left: 10, top: 20 }
+    let recordedPosition = { ...currentPosition }
+    const finalPosition = { left: 80, top: 120 }
+
+    completeModelMove(
+      finalPosition,
+      (position) => {
+        currentPosition = position
+      },
+      () => {
+        recordedPosition = { ...currentPosition }
+      }
+    )
+
+    expect(recordedPosition).toEqual(finalPosition)
+  })
+})

+ 14 - 0
src/components/mt-edit/utils/canvas-pan.ts

@@ -0,0 +1,14 @@
+export interface CanvasPanGesture {
+  button: number
+  panModeEnabled: boolean
+  systemShortcutPressed: boolean
+  canvasLocked: boolean
+}
+
+export const shouldStartCanvasPan = ({
+  button,
+  panModeEnabled,
+  systemShortcutPressed,
+  canvasLocked
+}: CanvasPanGesture) =>
+  canvasLocked || systemShortcutPressed || button === 2 || (panModeEnabled && button === 0)

+ 20 - 0
src/components/mt-edit/utils/editor-document.ts

@@ -0,0 +1,20 @@
+import type { IExportJson } from '../components/types'
+import type { IEditorDocumentCanvasCfg, IGlobalStoreCanvasCfg } from '../store/types'
+
+export const getEditorDocumentCanvasCfg = (
+  canvasCfg: IGlobalStoreCanvasCfg
+): IEditorDocumentCanvasCfg => ({
+  width: canvasCfg.width,
+  height: canvasCfg.height,
+  color: canvasCfg.color,
+  img: canvasCfg.img,
+  guide: canvasCfg.guide,
+  adsorp: canvasCfg.adsorp,
+  adsorp_diff: canvasCfg.adsorp_diff
+})
+
+export const serializeEditorDocument = (exportJson: IExportJson) =>
+  JSON.stringify({
+    ...exportJson,
+    canvasCfg: getEditorDocumentCanvasCfg(exportJson.canvasCfg)
+  })