服务端如何实现撤销恢复功能

撤销恢复功能

在文本编辑、图像与绘图设计、办公表格与演示文稿制作、代码编写等各类软件通常都需要撤销恢复功能。 有了撤销恢复功能,用户就不用担心误操作了,大可以放开手脚探索更多可能,犯错了也能快速恢复。不管是点击、敲代码,还是画画,都能轻松撤回、恢复,操作起来更灵活。

核心逻辑

如何描述步骤变更

  1. 假设我们画布上有一个初始的元素【步骤1】,我们用最简单的方式描述它就是 {x: 100, y: 100, width: 80, height: 30,bgColor: 'yellow'}
  2. 然后我们将这个元素拖到下方并更改它的大小,对它的描述就是 {x: 140, y: 160, width: 120, height: 70,bgColor: 'yellow'}
  3. 继续移动元素,并修改它的背景颜色,对它的描述就是 {x: 100, y: 200, width: 120, height: 70,bgColor: 'red'} 

那么步骤1 - 步骤3 产生了哪些变更呢。 可以看到下面红色标注的就是每个步骤之间产生的变更。

存储步骤

  • 数据结构:每个步骤都有oldValuenewValue两个属性。oldValue指向上一个步骤的newValue ,用于记录上一状态;newValue记录当前步骤图形的相关属性(如坐标xy,宽width,高height ,背景颜色bgColor等)。

  • 步骤内容

    • step1oldValuenull ,因为没有上一步;newValue包含图形初始属性,坐标x:100y:100 ,宽width:80 ,高height:30 ,背景颜色bgColor: 'yellow'
    • step2oldValue指向 step1 的newValue ;newValue中图形属性变为x:140y:160 ,宽width:120 ,高height:70 。
    • step3oldValue指向 step2 的newValue ;newValue里图形属性更新为x:100y:200 ,宽width:120 ,高height:70 ,背景颜色bgColor: 'red' 。
  • 当前指针:图中箭头表示当前指针指向 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 将数据推送给客户端呢?

  1. 统一客户端处理逻辑
    客户端可以集中处理所有图形数据变更,逻辑内聚性更强。通过统一的 Step 数据结构接收和解析数据,避免了因数据格式不一致导致的处理逻辑分散和复杂性增加。
  2. 多场景通用性
    不论是图形属性修改、图形位置调整,还是图形的添加与删除,都可以使用同一套逻辑处理数据变更。这种通用性减少了重复代码,提高了代码的复用性和可维护性。
  3. 数据格式统一
    客户端和服务端定义了一套统一的变更数据结构(如 Step),使得数据交互更加规范和清晰。这种标准化的数据格式便于扩展和维护,同时也降低了因数据格式不一致导致的错误。
  4. 支持复杂交互
    基于 Socket 的双向通信机制,可以轻松实现多用户协同编辑等复杂功能。多个用户对图形的操作可以实时同步到其他用户的画布上,增强了软件的协作能力。
服务端推送步骤消息

以移动图形为例:

  1. 移动图形并记录一次操作的 Changes。
  2. 调用 initStep 创建 step 记录,并更新 currentStep。
  3. 将最新 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 }) } }

客户端处理消息
  1. 客户端通过 start 连接 socket。
  2. 通过 onmessage 监听服务端消息,并处理对应的逻辑。
  3. 对于 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 实时接收步骤数据,统一处理图形变更,支持复杂交互与协同编辑。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值