feat: 优化批处理和历史记录管理

This commit is contained in:
2026-08-15 22:56:47 +08:00
parent f99324adb4
commit f60946a24d
6 changed files with 139 additions and 29 deletions
+63 -12
View File
@@ -1,13 +1,44 @@
import { setValue, getValue } from '@/utils' import { setValue, getValue } from '@/utils'
// 最大历史记录长度
const MAX_HISTORY_SIZE = 1000
const undoStack: any[] = shallowReactive([]) const undoStack: any[] = shallowReactive([])
const redoStack: any[] = shallowReactive([]) const redoStack: any[] = shallowReactive([])
/** 所有 useUndoRedo() 共用,避免面板 batch 和工具栏 undo 各过各的 */
let activeBatch: { target: any; key: string; oldValue: any; newValue: any }[] | null = null
export function useUndoRedo() { export function useUndoRedo() {
// 是否可以撤销 // 是否可以撤销
const canUndo = computed(() => undoStack.length > 0) const canUndo = computed(() => undoStack.length > 0)
// 是否可以重做 // 是否可以重做
const canRedo = computed(() => redoStack.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) { function applyChange(target: any, key: string, newValue: any) {
const oldValue = getValue(target, key) const oldValue = getValue(target, key)
@@ -19,7 +50,19 @@ export function useUndoRedo() {
oldValue, oldValue,
newValue, 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) setValue(target, key, newValue)
// 清空重做栈 // 清空重做栈
redoStack.length = 0 redoStack.length = 0
@@ -27,24 +70,30 @@ export function useUndoRedo() {
// 撤销 // 撤销
function undo() { function undo() {
const record = undoStack.pop() // 输入框还没 blur 时,先把当前编辑收成一条,再撤销
if (!record) return 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(records)
redoStack.push(record)
} }
// 重做 // 重做
function redo() { function redo() {
const record = redoStack.pop() commitBatch()
if (!record) return const records = redoStack.pop()
if (!records) return
const { target, key, newValue } = record records.forEach((record: any) => {
const { target, key, newValue } = record
setValue(target, key, newValue) setValue(target, key, newValue)
undoStack.push(record) })
pushRecord(records)
} }
return { return {
@@ -53,5 +102,7 @@ export function useUndoRedo() {
applyChange, applyChange,
undo, undo,
redo, redo,
startBatch,
commitBatch,
} }
} }
+37 -5
View File
@@ -1,11 +1,13 @@
import { useUndoRedo } from '@/composables/useUndoRedo'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import type { OnDrag, OnResize } from 'vue3-moveable' import type { OnDrag, OnResize } from 'vue3-moveable'
/** /**
* Moveable 单选 / 组拖拽与缩放 * Moveable 单选 / 组拖拽与缩放
*/ */
export const useMoveable = () => { export const useMoveable = (moveableRef: any) => {
const editorStore = useEditorStore() const editorStore = useEditorStore()
const { applyChange, startBatch, commitBatch } = useUndoRedo()
/** 从节点 DOM 反查 store 节点 */ /** 从节点 DOM 反查 store 节点 */
const getNodeByTarget = (target: HTMLElement) => { const getNodeByTarget = (target: HTMLElement) => {
@@ -14,16 +16,38 @@ export const useMoveable = () => {
return editorStore.findNodeById(nodeId) 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) => { const onDrag = (e: OnDrag) => {
console.log('拖拽事件', e)
e.target.style.setProperty('left', `${e.left}px`) e.target.style.setProperty('left', `${e.left}px`)
e.target.style.setProperty('top', `${e.top}px`) e.target.style.setProperty('top', `${e.top}px`)
const node = getNodeByTarget(e.target as HTMLElement) const node = getNodeByTarget(e.target as HTMLElement)
console.log('拖拽节点', node)
if (!node) return if (!node) return
node.layout.x = e.left applyChange(node, 'layout', {
node.layout.y = e.top ...node.layout,
x: e.left,
y: e.top,
})
} }
/** 缩放节点 */ /** 缩放节点 */
@@ -34,6 +58,12 @@ export const useMoveable = () => {
if (!node) return if (!node) return
node.layout.width = e.width node.layout.width = e.width
node.layout.height = e.height node.layout.height = e.height
applyChange(node, 'layout', {
...node.layout,
width: e.width,
height: e.height,
})
// 处理左侧圆点的拖拽,改变节点位置 // 处理左侧圆点的拖拽,改变节点位置
onDrag(e.drag) onDrag(e.drag)
} }
@@ -53,5 +83,7 @@ export const useMoveable = () => {
onResize, onResize,
onDragGroup, onDragGroup,
onResizeGroup, onResizeGroup,
onStart,
onEnd,
} }
} }
+9 -1
View File
@@ -26,7 +26,7 @@ const canvasRootRef = ref<HTMLElement | null>(null)
const { RULER_THICK, scale, lines, rulerCanvasSize, viewportSize, palette, onZoomChange } = const { RULER_THICK, scale, lines, rulerCanvasSize, viewportSize, palette, onZoomChange } =
useCanvasRuler(moveableRef, canvasRootRef) 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) const { selectedTarget, onSelect, onClearSelect, onSelectEnd } = useSelection(stageRef, moveableRef)
/** 设计稿白底:宽高和背景色立刻跟 store */ /** 设计稿白底:宽高和背景色立刻跟 store */
@@ -155,9 +155,17 @@ const onCommand = (command: keyof typeof commendMap) => {
:resizable="true" :resizable="true"
:origin="false" :origin="false"
@drag="onDrag" @drag="onDrag"
@drag-start="onStart"
@drag-end="onEnd"
@drag-group="onDragGroup" @drag-group="onDragGroup"
@drag-group-start="onStart"
@drag-group-end="onEnd"
@resize="onResize" @resize="onResize"
@resize-start="onStart"
@resize-end="onEnd"
@resize-group="onResizeGroup" @resize-group="onResizeGroup"
@resize-group-start="onStart"
@resize-group-end="onEnd"
/> />
</div> </div>
</template> </template>
@@ -5,7 +5,7 @@ import { useUndoRedo } from '@/composables/useUndoRedo'
defineProps(['setters', 'formData']) defineProps(['setters', 'formData'])
const { applyChange } = useUndoRedo() const { applyChange, startBatch, commitBatch } = useUndoRedo()
const componentMap: Record<string, any> = { const componentMap: Record<string, any> = {
input: ElInput, input: ElInput,
@@ -22,6 +22,8 @@ const componentMap: Record<string, any> = {
:is="componentMap[setter.type]" :is="componentMap[setter.type]"
:model-value="getValue(formData, setter.key)" :model-value="getValue(formData, setter.key)"
@update:model-value="(value: any) => applyChange(formData, setter.key, value)" @update:model-value="(value: any) => applyChange(formData, setter.key, value)"
@focus="startBatch()"
@blur="commitBatch()"
/> />
</el-form-item> </el-form-item>
</el-form> </el-form>
+21 -8
View File
@@ -1,6 +1,9 @@
import type { MaterialSchema } from '@/schema/materials' import type { MaterialSchema } from '@/schema/materials'
import type { PageSchema } from '@/schema/page' import type { PageSchema } from '@/schema/page'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useUndoRedo } from '@/composables/useUndoRedo'
const { applyChange, commitBatch } = useUndoRedo()
export const useEditorStore = defineStore('editor', () => { export const useEditorStore = defineStore('editor', () => {
// 面板显示状态 // 面板显示状态
@@ -34,9 +37,16 @@ export const useEditorStore = defineStore('editor', () => {
return nodes.value.find((node) => node.id === selectedNodeId.value) || null return nodes.value.find((node) => node.id === selectedNodeId.value) || null
}) })
function setNodes(newNodes: MaterialSchema[]) {
// 先收掉属性面板未提交的输入,避免和整表替换叠成一次撤销
commitBatch()
applyChange(nodes, 'value', newNodes)
}
/* 添加节点 */ /* 添加节点 */
function addNode(node: MaterialSchema) { 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) { 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) selectedNodeIds.value = selectedNodeIds.value.filter((id) => id !== node.id)
} }
// 置顶节点 // 置顶节点
function moveTop(node: MaterialSchema) { function moveTop(node: MaterialSchema) {
const index = nodes.value.findIndex((n) => n.id === node.id) const index = nodes.value.findIndex((n) => n.id === node.id)
nodes.value.splice(index, 1) const splicedNodes = nodes.value.splice(index, 1)
nodes.value.unshift(node)
setNodes([node, ...splicedNodes])
} }
// 置节点 // 置节点
function moveBottom(node: MaterialSchema) { function moveBottom(node: MaterialSchema) {
const index = nodes.value.findIndex((n) => n.id === node.id) const index = nodes.value.findIndex((n) => n.id === node.id)
nodes.value.splice(index, 1) const splicedNodes = nodes.value.splice(index, 1)
nodes.value.push(node)
setNodes([...splicedNodes, node])
} }
// 锁定节点 // 锁定节点
function toggleLock(node: MaterialSchema) { function toggleLock(node: MaterialSchema) {
node.locked = !node.locked applyChange(node, 'locked', !node.locked)
} }
return { return {
+6 -2
View File
@@ -12,6 +12,10 @@ export function getValue(target: any, key: string) {
export function setValue(target: any, key: string, value: any) { export function setValue(target: any, key: string, value: any) {
const keys = key.split('.') const keys = key.split('.')
const lastKey = keys.pop()! const lastKey = keys.pop()!
const _target = getValue(target, keys.join('.'))
_target[lastKey] = value if (keys.length) {
target = getValue(target, keys.join('.'))
}
target[lastKey] = value
} }