撤销恢复功能
在文本编辑、图像与绘图设计、办公表格与演示文稿制作、代码编写等各类软件通常都需要撤销恢复功能。 有了撤销恢复功能,用户就不用担心误操作了,大可以放开手脚探索更多可能,犯错了也能快速恢复。不管是点击、敲代码,还是画画,都能轻松撤回、恢复,操作起来更灵活。
核心逻辑
如何描述步骤变更
- 假设我们画布上有一个初始的元素【步骤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时,执行以下逻辑
// 假设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时,执行以下逻辑
// 假设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强制要求 每个步骤必须包含至少一条修改记录,避免出现 “空步骤” 的无效数据。
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: 所属项目
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,每个项目有自己的步骤指针记录。
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 记录同步给客户端
// 服务端实现
@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 反转一下,因为步骤前后可能有依赖,所以要一步步会退回去。
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 实时接收步骤数据,统一处理图形变更,支持复杂交互与协同编辑。
回顾整个过程,我们成功实现了高效、灵活的撤销恢复功能,提升了软件的可靠性和用户体验。
在下一篇文章我会继续介绍如何加入事务处理,将每个项目存储为一个文件,保存相关的项目数据库文件,并添加应用数据库。如果感兴趣可以持续关注!