撤销恢复功能
在文本编辑、图像与绘图设计、办公表格与演示文稿制作、代码编写等各类软件通常都需要撤销恢复功能。 有了撤销恢复功能,用户就不用担心误操作了,大可以放开手脚探索更多可能,犯错了也能快速恢复。不管是点击、敲代码,还是画画,都能轻松撤回、恢复,操作起来更灵活。
核心逻辑
如何描述步骤变更
- 假设我们画布上有一个初始的元素【步骤1】,我们用最简单的方式描述它就是
{x: 100, y: 100, width: 80, height: 30,bgColor: 'yellow'}
- 然后我们将这个元素拖到下方并更改它的大小,对它的描述就是
{x: 140, y: 160, width: 120, height: 70,bgColor: 'yellow'}
- 继续移动元素,并修改它的背景颜色,对它的描述就是
{x: 100, y: 200, width: 120, height: 70,bgColor: 'red'}
那么步骤1 - 步骤3 产生了哪些变更呢。 可以看到下面红色标注的就是每个步骤之间产生的变更。
存储步骤
-
数据结构:每个步骤都有
oldValue
和newValue
两个属性。oldValue
指向上一个步骤的newValue
,用于记录上一状态;newValue
记录当前步骤图形的相关属性(如坐标x
、y
,宽width
,高height
,背景颜色bgColor
等)。 -
步骤内容
- step1:
oldValue
为null
,因为没有上一步;newValue
包含图形初始属性,坐标x:100
,y:100
,宽width:80
,高height:30
,背景颜色bgColor: 'yellow'
。 - step2:
oldValue
指向 step1 的newValue
;newValue
中图形属性变为x:140
,y:160
,宽width:120
,高height:70
。 - step3:
oldValue
指向 step2 的newValue
;newValue
里图形属性更新为x:100
,y:200
,宽width:120
,高height:70
,背景颜色bgColor: 'red'
。
- step1:
-
当前指针:图中箭头表示当前指针指向 step3 ,意味着当前处于 step3 这个操作步骤。 整体通过这种数据结构记录各步骤图形状态变化,为实现撤销恢复功能提供基础。
撤销逻辑
伪代码如下:
- 假设 steps 就是上图中的步骤栈
- currentIndex 代表指针
- 当我们要完成从步骤3撤销到步骤2时,执行以下逻辑
javascript
体验AI代码助手
代码解读
复制代码
// 假设steps数组存储所有步骤,currentIndex指向当前步骤(这里是step3,假设索引为2 ) const steps = [step1, step2, step3]; let currentIndex = 2; // 获取step3的oldValue ,即step2的状态 const prevState = steps[currentIndex].oldValue; // 更新当前状态为prevState currentShape = {...prevState }; // 将当前指针往前移动一位 currentIndex--;
恢复逻辑
- 当我们要完成从步骤2恢复到步骤3时,执行以下逻辑
javascript
体验AI代码助手
代码解读
复制代码
// 假设steps数组存储所有步骤,currentIndex指向当前步骤(这里是step2,假设索引为1 ) const steps = [step1, step2, step3]; let currentIndex = 1; // 获取step2的newValue ,即step3的状态 const nextState = steps[currentIndex].newValue; // 更新当前状态为nextState currentShape = {...nextState }; // 将当前指针往后移动一位 currentIndex++;
代码实现
有了上面的核心逻辑的基础,我来考虑如何在服务端实现撤销恢复的功能。
表设计
step 表(步骤记录)
- projectId:项目id,每个项目有自己的一个步骤记录栈。
- step:一个
step
(步骤)可以包含 多个change
修改操作,例如在某个步骤中对节点进行多次编辑、删除或新增等操作,这些操作会被批量记录在同一个step
的changes
数组中。 - index:表示步骤所处的序号,当执行撤销或恢复时将对应指针修改为对应 index。
nullable: false
强制要求 每个步骤必须包含至少一条修改记录,避免出现 “空步骤” 的无效数据。
typescript
体验AI代码助手
代码解读
复制代码
import { Change } from '@hfdraw/types'; import { Entity, Column, PrimaryColumn, Index } from 'typeorm'; @Entity({ name: 'step' }) export class StepEntity { @PrimaryColumn() id_: string; @Column({ nullable: false, type: Number }) @Index("projectId") projectId: string; // 项目id @Column({ type: 'simple-json', nullable: false }) changes: Change[]; @Column({ type: String, default: '' }) desc = ''; // 描述 @Column({ nullable: true, type: Number }) index: number; // 序号,第几步, 从0开始 }
Change 对象类型
- ChangeType: 表示一个 Change 对下变更属于的操作类型。
- oldValue: 用于撤销时还原上一步。
- newValue: 用于恢复时还原下一步。
- shapeId: 用于将这些变更应用到哪个具体的图形。
- projectId: 所属项目
typescript
体验AI代码助手
代码解读
复制代码
export enum ChangeType { INSERT = 1, // 插入对象 UPDATE = 2, // 更新某个或多个字段 DELETE = 3, // 删除对象 } export interface Change { type: ChangeType; oldValue?: string; // 更新前的key-value对象的 json串,只记录变更的字段即可,undo的时候会用这个keyValue去update对应的table newValue?: string; // 更新后的key-value对象的 json串,只记录变更的字段即可,redo的时候会用这个keyValue去update对应的table shapeId: number // 当前操作的图形 id_ projectId: string }
currentStep
- stepSize: 步骤的总数,当我们操作一次时,步骤总数就会增加1,用于判断是否能够进行恢复功能。
- index: 指针指向对应的步骤序号。用于判断是否可以进行撤销功能。当 index=0时就不可以再撤销了。
- stepId: 对应的 stepId,用于找到对应的 step 记录。
- projectId:项目id,每个项目有自己的步骤指针记录。
typescript
体验AI代码助手
代码解读
复制代码
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn, RelationId } from "typeorm"; @Entity({ name: 'current_step' }) export class CurrentStep { @PrimaryGeneratedColumn() id_: number; @Column({ type: Number, nullable: false, default: 0 }) stepSize: number; // step的总数,用于判断是否有下一步 @Column({ type: Number, nullable: false, default: 0 }) index: number // 当前步骤对应的序号 @Column({ type: String, nullable: true }) stepId:string|null // 无记录时 stepId 为 null @Column({ type: String, nullable: false }) projectId:string }
socket 消息推送到客户端
设计 socket 消息统一推送
当我们调用接口接口的时候,为啥不直接将数据返回客户端,还要通过 socket 将数据推送给客户端呢?
- 统一客户端处理逻辑
客户端可以集中处理所有图形数据变更,逻辑内聚性更强。通过统一的Step
数据结构接收和解析数据,避免了因数据格式不一致导致的处理逻辑分散和复杂性增加。 - 多场景通用性
不论是图形属性修改、图形位置调整,还是图形的添加与删除,都可以使用同一套逻辑处理数据变更。这种通用性减少了重复代码,提高了代码的复用性和可维护性。 - 数据格式统一
客户端和服务端定义了一套统一的变更数据结构(如Step
),使得数据交互更加规范和清晰。这种标准化的数据格式便于扩展和维护,同时也降低了因数据格式不一致导致的错误。 - 支持复杂交互
基于 Socket 的双向通信机制,可以轻松实现多用户协同编辑等复杂功能。多个用户对图形的操作可以实时同步到其他用户的画布上,增强了软件的协作能力。
服务端推送步骤消息
以移动图形为例:
- 移动图形并记录一次操作的 Changes。
- 调用 initStep 创建 step 记录,并更新 currentStep。
- 将最新 step 记录同步给客户端
typescript
体验AI代码助手
代码解读
复制代码
// 服务端实现 @Post('move') async moveShape(@Body() dto: MoveShapeDto) { const changes = await this.shapeService.moveShape(dto); await this.stepService.initStep({ projectId: dto.projectId, changes }); await this.wsService.sendToSubscribedClient(dto.projectId, { type: WsMessageType.step, data: { projectId: dto.projectId, changes, stepType: StepType.edit, }, }); return new ResData(null); } // 生成一个 step,并且更新 currentStep async initStep(dto: { projectId: string, changes: Change[] }) { const step = await this.createStep({projectId: dto.projectId, changes: dto.changes}); const currentStep = await this.currentStepService.findCurrentStep(dto.projectId); const stepSize = await this.stepRepository.count(); if (currentStep) { await this.currentStepService.updateCurrentStep(currentStep.id_, {projectId: dto.projectId,stepId: step.id_, stepSize: stepSize, index: step.index}) } else { await this.currentStepService.createCurrentStep({ projectId: dto.projectId, stepId: step.id_, index: step.index, stepSize: stepSize }) } }
客户端处理消息
- 客户端通过 start 连接 socket。
- 通过 onmessage 监听服务端消息,并处理对应的逻辑。
- 对于 step 消息,遍历 changes 并将消息 emit 出去,在使用了图形信息的地方监听,并撤销或者恢复对应的变更。
- 注意点是,如果是撤销操作,先将 changes 反转一下,因为步骤前后可能有依赖,所以要一步步会退回去。
javascript
体验AI代码助手
代码解读
复制代码
export class SocketService { ws: WebSocket | undefined = undefined; reconnectTime = 0; status: ConnectStatus = ConnectStatus.UNCONNECT; uri: string; maxReconnectTime = 3; msgHandler: {[key:string]:Function} = { connect:() => { this.sendJSON({ type: "subscribeProject", projectId: 'p1' }); }, async step(messageData:{ type:'step', data: Step}) { let { data: { changes, stepType } } = messageData; const isUndo = stepType === StepType.undo; const isEdit = stepType === StepType.edit; // 如果是撤销操作,先将 changes 反转一下,因为步骤前后可能有依赖,所以要一步步会退回去 if (isUndo) { changes.reverse(); } changes.forEach(change => { if (change.type === ChangeType.INSERT) { if (isUndo) { emitter.emit(BusEvent.DELETE_SHAPE, change) } else { emitter.emit(BusEvent.INSERT_SHAPE, change); } } else if (change.type === ChangeType.DELETE) { if (isUndo) { emitter.emit(BusEvent.INSERT_SHAPE, change) } else { emitter.emit(BusEvent.DELETE_SHAPE, change) } } else if (change.type === ChangeType.UPDATE) { if (isUndo) { const oldValue = change.newValue; const newValue = change.oldValue; emitter.emit(BusEvent.UPDATE_SHAPE, {...change,oldValue, newValue}); } else { emitter.emit(BusEvent.UPDATE_SHAPE, change); } } }) } }; constructor(option: SocketOption) { const { uri, maxReconnectTime } = option; this.uri = uri; this.maxReconnectTime = maxReconnectTime; } start() { if (this.ws) { try { this.ws?.close(); } catch (error) { console.error(error); } } this.ws = new WebSocket(this.uri + "?clientId=0"); this.ws.onopen = this.onOpen.bind(this); this.ws.onmessage = this.onMessage.bind(this); this.ws.onclose = this.onClose.bind(this); this.ws.onerror = this.onError.bind(this); } onOpen() { this.status = ConnectStatus.CONNECTED; this.reconnectTime = 0; } onMessage(e: MessageEvent<string>) { const res = JSON.parse(e.data) as { type: string; data: StepMessageData}; console.log('res:',res) res.type if (this.msgHandler[res.type]) { this.msgHandler[res.type](res); } else { console.error("[消息格式错误] unKnow msg type:" + res.type, res); } } onClose() { // 主动关闭,由于后端关闭都会触发此处的onClose if (this.status === ConnectStatus.CLOSED) { return; } } onError(e: Event) { console.error(e); } sendJSON(obj: any) { this.ws?.send(JSON.stringify(obj)); } }
6. 总结
本文详细介绍了软件撤销恢复功能的设计与实现。从功能重要性出发,我们认识到撤销恢复功能对提升用户体验和操作灵活性的关键作用。通过定义清晰的步骤变更逻辑和存储结构,我们实现了撤销与恢复的核心功能,支持多种操作类型。服务端采用 step
表和 currentStep
表记录变更,客户端通过 Socket 实时接收步骤数据,统一处理图形变更,支持复杂交互与协同编辑。