Khi phát triển các công cụ soạn thảo phía client — như trình soạn thảo WYSIWYG, trình tạo trang H5, hoặc nền tảng low-code — việc hỗ trợ thao tác quay lại (Undo) và phục hồi (Redo) là yêu cầu thiết yếu bên cạnh các phím tắt cơ bản như Ctrl+C/Ctrl+V. Thiết kế một hệ thống lịch sử hiệu quả đòi hỏi cân nhắc kỹ lưỡng về cách lưu trữ trạng thái và quản lý luồng điều khiển.
1. Lưu trạng thái nguyên bản hay lưu hành động?
Có hai hướng tiếp cận chính:
- Lưu snapshot toàn bộ: Mỗi lần thay đổi, sao lưu toàn bộ dữ liệu hiện tại (ví dụ:
['a', 'ab', 'abc']). Phù hợp với ứng dụng đơn giản, khối lượng dữ liệu nhỏ và ít loại thao tác (như trình soạn thảo văn bản thuần túy). - Lưu hành động (command pattern): Chỉ ghi lại mô tả thao tác dưới dạng đối tượng có cấu trúc (ví dụ:
{ op: 'insert', at: 2, text: 'x' }). Được ưu tiên trong hệ thống phức tạp như trình soạn thảo H5, vì tiết kiệm bộ nhớ và dễ mở rộng.
2. Triển khai mô hình lệnh tùy chỉnh
Với trình soạn thảo đa thành phần, ta định nghĩa các loại lệnh theo ngữ cảnh:
pageUpdate: Cập nhật thuộc tính chung của trang (tên, nền, kích thước)componentAdd: Thêm thành phần mới vào canvascomponentModify: Thay đổi thuộc tính của một thành phần (vị trí, màu sắc, nội dung)componentRemove: Xóa thành phần
Mỗi lệnh chứa cả execute() và undo() — cho phép khôi phục ngược lại một cách chính xác mà không cần giữ bản sao dữ liệu gốc.
3. Quản lý ngăn xếp lịch sử bằng con trỏ thông minh
Thay vì dùng mảng đơn thuần, ta xây dựng lớp CommandHistory với cơ chế con trỏ động và giới hạn dung lượng:
class CommandHistory {
constructor(maxSize = 100) {
this.stack = [];
this.cursor = -1;
this.maxSize = maxSize;
}
push(command) {
// Xóa phần lịch sử sau cursor nếu đang ở chế độ redo
if (this.cursor < this.stack.length - 1) {
this.stack.splice(this.cursor + 1);
}
this.stack.push(command);
this.cursor = this.stack.length - 1;
// Giới hạn kích thước
if (this.stack.length > this.maxSize) {
this.stack.shift();
this.cursor--;
}
}
undo() {
if (this.cursor < 0 || this.stack.length === 0) return null;
const cmd = this.stack[this.cursor];
cmd.undo();
this.cursor--;
return cmd;
}
redo() {
if (this.cursor >= this.stack.length - 1 || this.stack.length === 0) return null;
this.cursor++;
const cmd = this.stack[this.cursor];
cmd.execute();
return cmd;
}
canUndo() { return this.cursor >= 0; }
canRedo() { return this.cursor < this.stack.length - 1; }
}
4. Kích hoạt thao tác qua phím tắt một cách an toàn
Để tránh xung đột và đảm bảo tương thích trên nhiều nền tảng (macOS/Windows), ta sử dụng sự kiện `keydown` kết hợp kiểm tra trạng thái phím điều khiển:
class ShortcutHandler {
constructor(history) {
this.history = history;
this.isCtrlPressed = false;
this.isCmdPressed = false;
window.addEventListener('keydown', (e) => {
if (e.key === 'Control') this.isCtrlPressed = true;
if (e.key === 'Meta') this.isCmdPressed = true;
const isMac = navigator.platform.toUpperCase().includes('MAC');
const isCtrlActive = isMac ? this.isCmdPressed : this.isCtrlPressed;
if (isCtrlActive) {
switch (e.key.toLowerCase()) {
case 'z':
e.preventDefault();
if (e.shiftKey) this.history.redo();
else this.history.undo();
break;
case 'y':
e.preventDefault();
this.history.redo();
break;
}
}
});
window.addEventListener('keyup', (e) => {
if (e.key === 'Control') this.isCtrlPressed = false;
if (e.key === 'Meta') this.isCmdPressed = false;
});
window.addEventListener('blur', () => {
this.isCtrlPressed = false;
this.isCmdPressed = false;
});
}
}