From f60946a24d2f1b530f380a13a9d887440acb8fbe Mon Sep 17 00:00:00 2001 From: Marcus <1922576605@qq.com> Date: Sat, 15 Aug 2026 22:56:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=89=B9=E5=A4=84?= =?UTF-8?q?=E7=90=86=E5=92=8C=E5=8E=86=E5=8F=B2=E8=AE=B0=E5=BD=95=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/composables/useUndoRedo.ts | 75 ++++++++++++++++--- src/editor/canvas/composables/useMoveable.ts | 42 +++++++++-- src/editor/canvas/index.vue | 10 ++- .../panels/property/components/FormCreate.vue | 4 +- src/stores/editor.ts | 29 +++++-- src/utils/index.ts | 8 +- 6 files changed, 139 insertions(+), 29 deletions(-) diff --git a/src/composables/useUndoRedo.ts b/src/composables/useUndoRedo.ts index 32bf01e..ba8ce8e 100644 --- a/src/composables/useUndoRedo.ts +++ b/src/composables/useUndoRedo.ts @@ -1,13 +1,44 @@ import { setValue, getValue } from '@/utils' +// 最大历史记录长度 +const MAX_HISTORY_SIZE = 1000 + const undoStack: any[] = shallowReactive([]) const redoStack: any[] = shallowReactive([]) +/** 所有 useUndoRedo() 共用,避免面板 batch 和工具栏 undo 各过各的 */ +let activeBatch: { target: any; key: string; oldValue: any; newValue: any }[] | null = null + export function useUndoRedo() { // 是否可以撤销 const canUndo = computed(() => undoStack.length > 0) // 是否可以重做 const canRedo = computed(() => redoStack.length > 0) + + // 开始批处理(先提交上一批,避免 focus 重入把未提交记录丢掉) + function startBatch() { + if (activeBatch?.length) { + pushRecord(activeBatch) + } + activeBatch = [] + } + + // 提交批处理 + function commitBatch() { + if (activeBatch?.length) { + pushRecord(activeBatch) + } + activeBatch = null + } + + // 记录,如果超出最大历史记录长度,删除最早的记录 + function pushRecord(record: any) { + undoStack.push(record) + if (undoStack.length >= MAX_HISTORY_SIZE) { + undoStack.shift() + } + } + // 应用更改 function applyChange(target: any, key: string, newValue: any) { const oldValue = getValue(target, key) @@ -19,7 +50,19 @@ export function useUndoRedo() { oldValue, newValue, } - undoStack.push(record) + + if (activeBatch) { + // 如果当前处于批处理模式,检查是否已经存在相同的记录,如果存在则更新新值,否则添加新的记录 + const _record = activeBatch.find((r) => r.target === target && r.key === key) + if (_record) { + _record.newValue = newValue + } else { + activeBatch.push(record) + } + } else { + pushRecord([record]) + } + setValue(target, key, newValue) // 清空重做栈 redoStack.length = 0 @@ -27,24 +70,30 @@ export function useUndoRedo() { // 撤销 function undo() { - const record = undoStack.pop() - if (!record) return + // 输入框还没 blur 时,先把当前编辑收成一条,再撤销 + commitBatch() + const records = undoStack.pop() + if (!records) return - const { target, key, oldValue } = record + ;[...records].reverse().forEach((record: any) => { + const { target, key, oldValue } = record + setValue(target, key, oldValue) + }) - setValue(target, key, oldValue) - redoStack.push(record) + redoStack.push(records) } // 重做 function redo() { - const record = redoStack.pop() - if (!record) return + commitBatch() + const records = redoStack.pop() + if (!records) return - const { target, key, newValue } = record - - setValue(target, key, newValue) - undoStack.push(record) + records.forEach((record: any) => { + const { target, key, newValue } = record + setValue(target, key, newValue) + }) + pushRecord(records) } return { @@ -53,5 +102,7 @@ export function useUndoRedo() { applyChange, undo, redo, + startBatch, + commitBatch, } } diff --git a/src/editor/canvas/composables/useMoveable.ts b/src/editor/canvas/composables/useMoveable.ts index e3bcc2a..0cb11d1 100644 --- a/src/editor/canvas/composables/useMoveable.ts +++ b/src/editor/canvas/composables/useMoveable.ts @@ -1,11 +1,13 @@ +import { useUndoRedo } from '@/composables/useUndoRedo' import { useEditorStore } from '@/stores/editor' import type { OnDrag, OnResize } from 'vue3-moveable' /** * Moveable 单选 / 组拖拽与缩放 */ -export const useMoveable = () => { +export const useMoveable = (moveableRef: any) => { const editorStore = useEditorStore() + const { applyChange, startBatch, commitBatch } = useUndoRedo() /** 从节点 DOM 反查 store 节点 */ const getNodeByTarget = (target: HTMLElement) => { @@ -14,16 +16,38 @@ export const useMoveable = () => { return editorStore.findNodeById(nodeId) } + // 当节点布局发生变化时,更新 moveable 的选框位置和大小 + watch( + () => editorStore.nodes.map((node) => node.layout), + () => { + // 手动更新 + moveableRef.value?.updateRect(undefined, true) + }, + { + flush: 'post', // 在 DOM 更新后再更新 + }, + ) + + function onStart() { + startBatch() + } + + function onEnd() { + commitBatch() + } + /** 拖拽节点 */ const onDrag = (e: OnDrag) => { - console.log('拖拽事件', e) e.target.style.setProperty('left', `${e.left}px`) e.target.style.setProperty('top', `${e.top}px`) const node = getNodeByTarget(e.target as HTMLElement) - console.log('拖拽节点', node) + if (!node) return - node.layout.x = e.left - node.layout.y = e.top + applyChange(node, 'layout', { + ...node.layout, + x: e.left, + y: e.top, + }) } /** 缩放节点 */ @@ -34,6 +58,12 @@ export const useMoveable = () => { if (!node) return node.layout.width = e.width node.layout.height = e.height + applyChange(node, 'layout', { + ...node.layout, + width: e.width, + height: e.height, + }) + // 处理左侧圆点的拖拽,改变节点位置 onDrag(e.drag) } @@ -53,5 +83,7 @@ export const useMoveable = () => { onResize, onDragGroup, onResizeGroup, + onStart, + onEnd, } } diff --git a/src/editor/canvas/index.vue b/src/editor/canvas/index.vue index 0aff4aa..b2f0b1a 100644 --- a/src/editor/canvas/index.vue +++ b/src/editor/canvas/index.vue @@ -26,7 +26,7 @@ const canvasRootRef = ref(null) const { RULER_THICK, scale, lines, rulerCanvasSize, viewportSize, palette, onZoomChange } = useCanvasRuler(moveableRef, canvasRootRef) -const { onDrag, onResize, onDragGroup, onResizeGroup } = useMoveable() +const { onDrag, onResize, onDragGroup, onResizeGroup, onStart, onEnd } = useMoveable(moveableRef) const { selectedTarget, onSelect, onClearSelect, onSelectEnd } = useSelection(stageRef, moveableRef) /** 设计稿白底:宽高和背景色立刻跟 store */ @@ -155,9 +155,17 @@ const onCommand = (command: keyof typeof commendMap) => { :resizable="true" :origin="false" @drag="onDrag" + @drag-start="onStart" + @drag-end="onEnd" @drag-group="onDragGroup" + @drag-group-start="onStart" + @drag-group-end="onEnd" @resize="onResize" + @resize-start="onStart" + @resize-end="onEnd" @resize-group="onResizeGroup" + @resize-group-start="onStart" + @resize-group-end="onEnd" /> diff --git a/src/editor/panels/property/components/FormCreate.vue b/src/editor/panels/property/components/FormCreate.vue index 4c36274..6f52c30 100644 --- a/src/editor/panels/property/components/FormCreate.vue +++ b/src/editor/panels/property/components/FormCreate.vue @@ -5,7 +5,7 @@ import { useUndoRedo } from '@/composables/useUndoRedo' defineProps(['setters', 'formData']) -const { applyChange } = useUndoRedo() +const { applyChange, startBatch, commitBatch } = useUndoRedo() const componentMap: Record = { input: ElInput, @@ -22,6 +22,8 @@ const componentMap: Record = { :is="componentMap[setter.type]" :model-value="getValue(formData, setter.key)" @update:model-value="(value: any) => applyChange(formData, setter.key, value)" + @focus="startBatch()" + @blur="commitBatch()" /> diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 24daff0..ffecb93 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -1,6 +1,9 @@ import type { MaterialSchema } from '@/schema/materials' import type { PageSchema } from '@/schema/page' import { defineStore } from 'pinia' +import { useUndoRedo } from '@/composables/useUndoRedo' + +const { applyChange, commitBatch } = useUndoRedo() export const useEditorStore = defineStore('editor', () => { // 面板显示状态 @@ -34,9 +37,16 @@ export const useEditorStore = defineStore('editor', () => { return nodes.value.find((node) => node.id === selectedNodeId.value) || null }) + function setNodes(newNodes: MaterialSchema[]) { + // 先收掉属性面板未提交的输入,避免和整表替换叠成一次撤销 + commitBatch() + applyChange(nodes, 'value', newNodes) + } + /* 添加节点 */ function addNode(node: MaterialSchema) { - nodes.value.push(node) + // 记录操作到撤销栈 + setNodes([...nodes.value, node]) } /* 选中节点 */ @@ -72,27 +82,30 @@ export const useEditorStore = defineStore('editor', () => { // 删除节点 function removeNode(node: MaterialSchema) { - nodes.value = nodes.value.filter((n) => n.id !== node.id) + setNodes(nodes.value.filter((n) => n.id !== node.id)) + selectedNodeIds.value = selectedNodeIds.value.filter((id) => id !== node.id) } // 置顶节点 function moveTop(node: MaterialSchema) { const index = nodes.value.findIndex((n) => n.id === node.id) - nodes.value.splice(index, 1) - nodes.value.unshift(node) + const splicedNodes = nodes.value.splice(index, 1) + + setNodes([node, ...splicedNodes]) } - // 置顶节点 + // 置底节点 function moveBottom(node: MaterialSchema) { const index = nodes.value.findIndex((n) => n.id === node.id) - nodes.value.splice(index, 1) - nodes.value.push(node) + const splicedNodes = nodes.value.splice(index, 1) + + setNodes([...splicedNodes, node]) } // 锁定节点 function toggleLock(node: MaterialSchema) { - node.locked = !node.locked + applyChange(node, 'locked', !node.locked) } return { diff --git a/src/utils/index.ts b/src/utils/index.ts index e575690..7ae666c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -12,6 +12,10 @@ export function getValue(target: any, key: string) { export function setValue(target: any, key: string, value: any) { const keys = key.split('.') const lastKey = keys.pop()! - const _target = getValue(target, keys.join('.')) - _target[lastKey] = value + + if (keys.length) { + target = getValue(target, keys.join('.')) + } + + target[lastKey] = value }