前四讲我们实现了文档引擎、OT算法、WebSocket通信和操作同步。现在文档已经可以多人实时协作了,但还缺一个至关重要的用户体验功能——撤销(Undo)和重做(Redo)。
单机环境下的撤销很简单:维护一个操作栈。但在协作环境下,撤销变得复杂——你不能撤销别人的操作。
这一讲,我们来实现协作友好的撤销/重做机制。
一、撤销的挑战
1.1 协作撤销的问题
场景:三个人同时编辑
Alice: 插入 "Hello" (op1)
Bob: 插入 " World" (op2)
Carol: 插入 "!!!" (op3)
文档状态: "Hello World!!!"
问题:如果 Alice 想撤销自己的操作,会发生什么?
Alice 的 op1 是 "插入 Hello at 0"
但此时文档已经是 "Hello World!!!"
位置 0 已经被 Bob 和 Carol 的操作改变了!
简单撤销会导致:
- 删除了 "H" 而不是 "Hello"
- 破坏了 Bob 和 Carol 的操作
解决方案:使用 OT 变换来"逆向"操作
1.2 撤销的类型
| 类型 | 说明 | 实现难度 |
|---|---|---|
| 本地撤销 | 只撤销自己的上一个操作 | 简单 |
| 选择性撤销 | 撤销任意一个历史操作 | 中等 |
| 协作撤销 | 在多用户环境下正确撤销 | 困难 |
| 时间旅行 | 回退到任意历史版本 | 困难 |
二、撤销栈管理
2.1 撤销栈实现
# core/undo/undostack.py
"""
撤销栈管理
支持协作环境下的撤销和重做。
"""
from __future__ import annotations
from typing import List, Optional, Dict, Tuple
from dataclasses import dataclass, field
from enum import Enum
import time
import logging
from core.ot.operation import OTOperation, OpType
from core.ot.transform import transform, invert
logger = logging.getLogger(__name__)
class UndoGroup(Enum):
"""撤销分组"""
TYPING = "typing" # 打字(自动合并)
PASTE = "paste" # 粘贴
DELETE = "delete" # 删除
FORMAT = "format" # 格式化
OTHER = "other" # 其他
@dataclass
class UndoEntry:
"""
撤销条目
记录一次操作及其上下文信息。
"""
op: OTOperation # 原始操作
user_id: str # 执行操作的用户
group: UndoGroup = UndoGroup.OTHER # 分组
timestamp: float = 0.0 # 时间戳
merged: bool = False # 是否已被合并
def __post_init__(self):
if not self.timestamp:
self.timestamp = time.time()
@property
def inverse_op(self) -> OTOperation:
"""获取逆操作"""
return invert(self.op)
def to_dict(self) -> dict:
return {
'op': self.op.to_dict(),
'user_id': self.user_id,
'group': self.group.value,
'timestamp': self.timestamp,
'merged': self.merged
}
class UndoStack:
"""
撤销栈
管理操作历史,支持撤销和重做。
特点:
- 按用户隔离(只能撤销自己的操作)
- 自动合并连续的同类型操作
- 支持选择性撤销
"""
def __init__(self, max_size: int = 1000):
self.max_size = max_size
# 撤销栈
self.undo_stack: List[UndoEntry] = []
# 重做栈
self.redo_stack: List[UndoEntry] = []
# 合并配置(毫秒)
self.merge_window_ms = 500 # 500ms 内的连续操作合并
# 回调
self.on_undo: Optional[callable] = None
self.on_redo: Optional[callable] = None
# ---------- 操作记录 ----------
def push(self, op: OTOperation, user_id: str,
group: UndoGroup = UndoGroup.OTHER):
"""
记录操作到撤销栈
Args:
op: 操作
user_id: 用户 ID
group: 操作分组
"""
entry = UndoEntry(
op=op,
user_id=user_id,
group=group
)
# 尝试合并
if self._try_merge(entry):
logger.debug(f"Merged op into existing entry: {op}")
return
# 添加到撤销栈
self.undo_stack.append(entry)
# 限制大小
if len(self.undo_stack) > self.max_size:
self.undo_stack.pop(0)
# 新操作清空重做栈
self.redo_stack.clear()
logger.debug(f"Pushed to undo stack: {op}")
def _try_merge(self, new_entry: UndoEntry) -> bool:
"""
尝试合并操作
合并条件:
1. 同一个用户
2. 同一分组
3. 时间在合并窗口内
4. 操作类型相同
"""
if not self.undo_stack:
return False
last = self.undo_stack[-1]
if last.user_id != new_entry.user_id:
return False
if last.group != new_entry.group:
return False
elapsed = (new_entry.timestamp - last.timestamp) * 1000
if elapsed > self.merge_window_ms:
return False
# 尝试合并操作
from core.ot.transform import compose
merged_op = compose(last.op, new_entry.op)
if merged_op != new_entry.op: # 如果可以合并
last.op = merged_op
last.timestamp = new_entry.timestamp
last.merged = True
return True
return False
# ---------- 撤销 ----------
def undo(self, user_id: str) -> Optional[UndoEntry]:
"""
撤销用户的上一个操作
Args:
user_id: 用户 ID
Returns:
撤销的条目(包含逆操作)
"""
# 找到该用户最近的未合并操作
for i in range(len(self.undo_stack) - 1, -1, -1):
entry = self.undo_stack[i]
if entry.user_id == user_id and not entry.merged:
# 从撤销栈移除
self.undo_stack.pop(i)
# 添加到重做栈
self.redo_stack.append(entry)
logger.info(f"Undo: {entry.op}")
if self.on_undo:
self.on_undo(entry)
return entry
return None
def undo_specific(self, index: int) -> Optional[UndoEntry]:
"""
撤销指定索引的操作
Args:
index: 在撤销栈中的索引
Returns:
撤销的条目
"""
if index < 0 or index >= len(self.undo_stack):
return None
entry = self.undo_stack.pop(index)
self.redo_stack.append(entry)
logger.info(f"Selective undo: {entry.op}")
if self.on_undo:
self.on_undo(entry)
return entry
# ---------- 重做 ----------
def redo(self, user_id: str) -> Optional[UndoEntry]:
"""
重做用户的上一个撤销
Args:
user_id: 用户 ID
Returns:
重做的条目
"""
for i in range(len(self.redo_stack) - 1, -1, -1):
entry = self.redo_stack[i]
if entry.user_id == user_id:
self.redo_stack.pop(i)
self.undo_stack.append(entry)
logger.info(f"Redo: {entry.op}")
if self.on_redo:
self.on_redo(entry)
return entry
return None
# ---------- 状态查询 ----------
def can_undo(self, user_id: str) -> bool:
"""用户是否可以撤销"""
return any(
e.user_id == user_id and not e.merged
for e in self.undo_stack
)
def can_redo(self, user_id: str) -> bool:
"""用户是否可以重做"""
return any(
e.user_id == user_id
for e in self.redo_stack
)
def get_user_history(self, user_id: str) -> List[UndoEntry]:
"""获取用户的操作历史"""
return [
e for e in self.undo_stack
if e.user_id == user_id and not e.merged
]
def get_stats(self) -> dict:
"""获取统计信息"""
return {
'undo_size': len(self.undo_stack),
'redo_size': len(self.redo_stack),
'max_size': self.max_size,
'users': len(set(e.user_id for e in self.undo_stack))
}
三、协作撤销引擎
3.1 撤销操作变换
# core/undo/undo_engine.py
"""
协作撤销引擎
处理协作环境下的撤销操作变换。
"""
from __future__ import annotations
from typing import List, Optional, Tuple
from dataclasses import dataclass
import logging
from core.ot.operation import OTOperation, OpType
from core.ot.transform import transform, invert
from core.undo.undostack import UndoStack, UndoEntry
logger = logging.getLogger(__name__)
class UndoEngine:
"""
撤销引擎
处理撤销操作的 OT 变换,确保在协作环境下的正确性。
"""
def __init__(self, site_id: str):
self.site_id = site_id
self.undo_stack = UndoStack()
# 待处理的撤销操作
self.pending_undos: List[UndoEntry] = []
def record_op(self, op: OTOperation):
"""
记录操作
Args:
op: 执行的操作
"""
self.undo_stack.push(op, self.site_id)
def perform_undo(self) -> Optional[OTOperation]:
"""
执行撤销
获取逆操作,并根据后续操作进行变换。
Returns:
变换后的逆操作
"""
entry = self.undo_stack.undo(self.site_id)
if not entry:
return None
# 获取逆操作
inverse = entry.inverse_op
# 对逆操作进行变换(考虑后续的操作)
transformed_inverse = self._transform_for_undo(inverse, entry)
logger.info(f"Performing undo: {entry.op} → {transformed_inverse}")
return transformed_inverse
def perform_redo(self) -> Optional[OTOperation]:
"""
执行重做
Returns:
变换后的原操作
"""
entry = self.undo_stack.redo(self.site_id)
if not entry:
return None
# 对原操作进行变换
transformed_op = self._transform_for_redo(entry.op)
logger.info(f"Performing redo: {entry.op} → {transformed_op}")
return transformed_op
def _transform_for_undo(self, inverse: OTOperation,
entry: UndoEntry) -> OTOperation:
"""
为撤销变换逆操作
由于在原始操作之后可能有其他操作,
逆操作需要根据这些操作进行变换。
Args:
inverse: 逆操作
entry: 被撤销的条目
Returns:
变换后的逆操作
"""
transformed = inverse.clone()
# 找到被撤销操作之后的所有操作
later_ops = self._get_later_ops(entry)
# 依次变换
for later_op in later_ops:
_, transformed = transform(later_op, transformed)
return transformed
def _transform_for_redo(self, op: OTOperation) -> OTOperation:
"""
为重做变换操作
Args:
op: 原始操作
Returns:
变换后的操作
"""
transformed = op.clone()
# 找到该操作之后的所有操作
later_ops = self._get_later_ops_since(op)
for later_op in later_ops:
_, transformed = transform(later_op, transformed)
return transformed
def _get_later_ops(self, entry: UndoEntry) -> List[OTOperation]:
"""
获取某个条目之后的所有操作
Args:
entry: 目标条目
Returns:
之后的操作列表
"""
later_ops = []
found = False
for e in self.undo_stack.undo_stack:
if e is entry:
found = True
continue
if found and not e.merged:
later_ops.append(e.op)
# 也包括重做栈中的操作
for e in self.undo_stack.redo_stack:
later_ops.append(e.op)
return later_ops
def _get_later_ops_since(self, op: OTOperation) -> List[OTOperation]:
"""
获取某个操作之后的所有操作
Args:
op: 目标操作
Returns:
之后的操作列表
"""
later_ops = []
found = False
for e in self.undo_stack.undo_stack:
if e.op is op:
found = True
continue
if found and not e.merged:
later_ops.append(e.op)
return later_ops
def get_status(self) -> dict:
"""获取状态"""
return {
'can_undo': self.undo_stack.can_undo(self.site_id),
'can_redo': self.undo_stack.can_redo(self.site_id),
'stack_stats': self.undo_stack.get_stats()
}
四、服务端撤销管理
4.1 服务端撤销处理
# server/undo_handler.py
"""
服务端撤销处理器
管理所有客户端的撤销请求。
"""
from __future__ import annotations
from typing import Dict, List, Optional
import logging
from core.ot.operation import OTOperation, OpType
from core.ot.transform import transform
from core.undo.undostack import UndoStack, UndoEntry
logger = logging.getLogger(__name__)
class ServerUndoManager:
"""
服务端撤销管理器
维护全局操作历史,处理客户端的撤销请求。
"""
def __init__(self):
# 全局操作历史
self.global_history: List[OTOperation] = []
# 每个用户的撤销栈
self.user_stacks: Dict[str, UndoStack] = {}
# 操作到用户的映射
self.op_owners: Dict[str, str] = {} # op_id -> user_id
def record_op(self, op: OTOperation, user_id: str):
"""
记录操作
Args:
op: 操作
user_id: 用户 ID
"""
self.global_history.append(op)
self.op_owners[op.id] = user_id
# 记录到用户的撤销栈
if user_id not in self.user_stacks:
self.user_stacks[user_id] = UndoStack()
self.user_stacks[user_id].push(op, user_id)
def handle_undo_request(self, user_id: str) -> Optional[OTOperation]:
"""
处理撤销请求
1. 找到用户最近的未撤销操作
2. 生成逆操作
3. 对逆操作进行全局变换
Args:
user_id: 请求撤销的用户
Returns:
变换后的逆操作
"""
stack = self.user_stacks.get(user_id)
if not stack:
return None
entry = stack.undo(user_id)
if not entry:
return None
# 获取逆操作
inverse = entry.inverse_op
# 找到该操作之后的所有全局操作
later_global_ops = self._get_later_global_ops(entry.op)
# 依次变换
transformed = inverse.clone()
for later_op in later_global_ops:
_, transformed = transform(later_op, transformed)
logger.info(f"Server undo for {user_id}: {entry.op} → {transformed}")
return transformed
def handle_redo_request(self, user_id: str) -> Optional[OTOperation]:
"""
处理重做请求
Args:
user_id: 请求重做的用户
Returns:
变换后的操作
"""
stack = self.user_stacks.get(user_id)
if not stack:
return None
entry = stack.redo(user_id)
if not entry:
return None
# 对该操作进行变换
transformed = entry.op.clone()
later_ops = self._get_later_global_ops(entry.op)
for later_op in later_ops:
_, transformed = transform(later_op, transformed)
logger.info(f"Server redo for {user_id}: {entry.op} → {transformed}")
return transformed
def _get_later_global_ops(self, op: OTOperation) -> List[OTOperation]:
"""
获取全局历史中某个操作之后的操作
Args:
op: 目标操作
Returns:
之后的操作列表
"""
later_ops = []
found = False
for hist_op in self.global_history:
if hist_op.id == op.id:
found = True
continue
if found:
later_ops.append(hist_op)
return later_ops
def can_undo(self, user_id: str) -> bool:
"""用户是否可以撤销"""
stack = self.user_stacks.get(user_id)
return stack.can_undo(user_id) if stack else False
def can_redo(self, user_id: str) -> bool:
"""用户是否可以重做"""
stack = self.user_stacks.get(user_id)
return stack.can_redo(user_id) if stack else False
五、集成到文档引擎
5.1 UndoDocument
# core/undo/undo_document.py
"""
支持撤销的文档引擎
"""
from typing import Optional, List
import logging
from core.ot.ot_document import OTDocument
from core.ot.operation import OTOperation, OpType
from core.undo.undo_engine import UndoEngine
logger = logging.getLogger(__name__)
class UndoDocument(OTDocument):
"""
支持撤销的文档引擎
继承自 OTDocument,增加撤销/重做功能。
"""
def __init__(self, document_id: str, site_id: str,
initial_text: str = ""):
super().__init__(document_id, site_id, initial_text)
self.undo_engine = UndoEngine(site_id)
# 撤销/重做回调
self.on_undo_performed: Optional[callable] = None
self.on_redo_performed: Optional[callable] = None
# ---------- 操作记录 ----------
def insert(self, position: int, text: str) -> OTOperation:
"""插入文本(自动记录撤销信息)"""
op = super().insert(position, text)
self.undo_engine.record_op(op)
return op
def delete(self, position: int, length: int) -> OTOperation:
"""删除文本(自动记录撤销信息)"""
op = super().delete(position, length)
self.undo_engine.record_op(op)
return op
# ---------- 撤销/重做 ----------
def undo(self) -> Optional[OTOperation]:
"""
撤销上一个操作
Returns:
执行的逆操作
"""
inverse_op = self.undo_engine.perform_undo()
if not inverse_op:
return None
# 应用逆操作
if inverse_op.op_type == OpType.INSERT:
super().insert(inverse_op.position, inverse_op.text)
elif inverse_op.op_type == OpType.DELETE:
super().delete(inverse_op.position,
len(inverse_op.deleted_text))
self.version += 1
if self.on_undo_performed:
self.on_undo_performed(inverse_op)
return inverse_op
def redo(self) -> Optional[OTOperation]:
"""
重做上一个撤销
Returns:
执行的操作
"""
op = self.undo_engine.perform_redo()
if not op:
return None
# 应用操作
if op.op_type == OpType.INSERT:
super().insert(op.position, op.text)
elif op.op_type == OpType.DELETE:
super().delete(op.position, len(op.deleted_text))
self.version += 1
if self.on_redo_performed:
self.on_redo_performed(op)
return op
# ---------- 状态查询 ----------
def can_undo(self) -> bool:
"""是否可以撤销"""
return self.undo_engine.undo_stack.can_undo(self.site_id)
def can_redo(self) -> bool:
"""是否可以重做"""
return self.undo_engine.undo_stack.can_redo(self.site_id)
def get_undo_status(self) -> dict:
"""获取撤销状态"""
return self.undo_engine.get_status()
六、演示
6.1 撤销功能演示
# examples/undo_demo.py
"""
撤销/重做功能演示
"""
import logging
import sys
sys.path.insert(0, '..')
from core.undo.undo_document import UndoDocument
from core.undo.undo_engine import UndoEngine
from core.ot.operation import OTOperation, OpType
logging.basicConfig(level=logging.INFO)
def demonstrate_basic_undo():
"""演示基本撤销功能"""
print("=" * 66)
print("↩️ 基本撤销/重做演示")
print("=" * 66)
doc = UndoDocument("doc-1", "Alice", "")
print(f"\n初始文档: '{doc.get_text()}'")
# 输入 "Hello"
print("\n--- 输入 'Hello' ---")
doc.insert(0, "H")
doc.insert(1, "e")
doc.insert(2, "l")
doc.insert(3, "l")
doc.insert(4, "o")
print(f"文档: '{doc.get_text()}'")
# 撤销
print("\n--- 撤销 3 次 ---")
for i in range(3):
op = doc.undo()
print(f"撤销: {op}")
print(f"文档: '{doc.get_text()}'")
# 重做
print("\n--- 重做 2 次 ---")
for i in range(2):
op = doc.redo()
print(f"重做: {op}")
print(f"文档: '{doc.get_text()}'")
print(f"\n最终文档: '{doc.get_text()}'")
print(f"撤销状态: {doc.get_undo_status()}")
def demonstrate_collaborative_undo():
"""演示协作撤销"""
print("\n" + "=" * 66)
print("👥 协作撤销演示")
print("=" * 66)
# 创建两个用户的文档
alice_doc = UndoDocument("doc-1", "Alice", "")
bob_doc = UndoDocument("doc-1", "Bob", "")
print(f"\n初始文档: '{alice_doc.get_text()}'")
# Alice 输入
print("\n--- Alice 输入 'Hello' ---")
op_a1 = alice_doc.insert(0, "Hello")
print(f"Alice 文档: '{alice_doc.get_text()}'")
# Bob 同步 Alice 的操作
bob_doc.receive_remote_op(op_a1)
print(f"Bob 文档: '{bob_doc.get_text()}'")
# Bob 输入
print("\n--- Bob 输入 ' World' ---")
op_b1 = bob_doc.insert(5, " World")
print(f"Bob 文档: '{bob_doc.get_text()}'")
# Alice 同步 Bob 的操作
alice_doc.receive_remote_op(op_b1)
print(f"Alice 文档: '{alice_doc.get_text()}'")
# Alice 撤销自己的操作
print("\n--- Alice 撤销 'Hello' ---")
inverse = alice_doc.undo()
print(f"Alice 撤销: {inverse}")
print(f"Alice 文档: '{alice_doc.get_text()}'")
# Bob 同步撤销
bob_doc.receive_remote_op(inverse)
print(f"Bob 文档: '{bob_doc.get_text()}'")
print(f"\n最终一致: {alice_doc.get_text() == bob_doc.get_text()}")
def demonstrate_selective_undo():
"""演示选择性撤销"""
print("\n" + "=" * 66)
print("🎯 选择性撤销演示")
print("=" * 66)
doc = UndoDocument("doc-1", "Alice", "")
# 执行一系列操作
print("\n--- 执行操作序列 ---")
ops = []
ops.append(doc.insert(0, "A")) # op1
ops.append(doc.insert(1, "B")) # op2
ops.append(doc.insert(2, "C")) # op3
ops.append(doc.insert(3, "D")) # op4
print(f"文档: '{doc.get_text()}'")
# 查看撤销栈
print("\n撤销栈:")
for i, entry in enumerate(doc.undo_engine.undo_stack.undo_stack):
print(f" [{i}] {entry.op}")
# 选择性撤销 op2(插入 "B")
print("\n--- 选择性撤销 op2 (插入 'B') ---")
entry = doc.undo_engine.undo_stack.undo_specific(2)
if entry:
inverse = entry.inverse_op
# 变换和应用省略...
print(f"选择的条目: {entry.op}")
print(f"逆操作: {inverse}")
def main():
demonstrate_basic_undo()
demonstrate_collaborative_undo()
demonstrate_selective_undo()
if __name__ == "__main__":
main()
七、测试
7.1 撤销测试
# tests/test_undo.py
import pytest
from core.undo.undostack import UndoStack, UndoEntry, UndoGroup
from core.undo.undo_engine import UndoEngine
from core.undo.undo_document import UndoDocument
from core.ot.operation import OTOperation, OpType
class TestUndoStack:
"""撤销栈测试"""
def test_push_and_undo(self):
stack = UndoStack()
op = OTOperation(OpType.INSERT, 0, text="Hello",
site_id="Alice", sequence=1)
stack.push(op, "Alice")
assert stack.can_undo("Alice")
assert not stack.can_undo("Bob")
entry = stack.undo("Alice")
assert entry is not None
assert entry.op is op
assert not stack.can_undo("Alice")
assert stack.can_redo("Alice")
def test_redo(self):
stack = UndoStack()
op = OTOperation(OpType.INSERT, 0, text="Hello",
site_id="Alice", sequence=1)
stack.push(op, "Alice")
stack.undo("Alice")
entry = stack.redo("Alice")
assert entry is not None
assert entry.op is op
def test_merge_typing(self):
stack = UndoStack(merge_window_ms=1000)
import time
ops = []
for i, ch in enumerate("Hello"):
op = OTOperation(OpType.INSERT, i, text=ch,
site_id="Alice", sequence=i)
stack.push(op, "Alice", UndoGroup.TYPING)
ops.append(op)
time.sleep(0.01)
# 应该合并为一个操作
assert len(stack.undo_stack) == 1
assert stack.undo_stack[0].merged
class TestUndoEngine:
"""撤销引擎测试"""
def test_undo_inverse(self):
engine = UndoEngine("Alice")
op = OTOperation(OpType.INSERT, 0, text="Hello",
site_id="Alice", sequence=1)
engine.record_op(op)
inverse = engine.perform_undo()
assert inverse is not None
assert inverse.op_type == OpType.DELETE
assert inverse.position == 0
assert inverse.deleted_text == "Hello"
class TestUndoDocument:
"""撤销文档测试"""
def test_undo_redo_cycle(self):
doc = UndoDocument("doc-1", "Alice", "")
doc.insert(0, "Hello")
assert doc.get_text() == "Hello"
doc.undo()
assert doc.get_text() == ""
doc.redo()
assert doc.get_text() == "Hello"
def test_collaborative_undo(self):
alice = UndoDocument("doc-1", "Alice", "")
bob = UndoDocument("doc-1", "Bob", "")
# Alice 输入
op_a = alice.insert(0, "Hello")
bob.receive_remote_op(op_a)
# Bob 输入
op_b = bob.insert(5, " World")
alice.receive_remote_op(op_b)
assert alice.get_text() == "Hello World"
assert bob.get_text() == "Hello World"
# Alice 撤销
inverse = alice.undo()
assert inverse is not None
# Bob 同步撤销
bob.receive_remote_op(inverse)
assert alice.get_text() == bob.get_text()
if __name__ == "__main__":
pytest.main([__file__, "-v"])
八、总结
8.1 本讲成果
| 组件 | 文件 | 功能 |
|---|---|---|
| UndoStack |
| 撤销栈管理,支持合并 |
| UndoEngine |
| 撤销操作变换 |
| ServerUndoManager |
| 服务端撤销管理 |
| UndoDocument |
| 支持撤销的文档引擎 |
8.2 核心知识点
-
撤销的本质:执行逆操作,但需要对后续操作进行变换
-
操作合并:连续的同类型操作合并为一个,提高撤销效率
-
用户隔离:每个用户只能撤销自己的操作
-
协作撤销:撤销操作本身也是一个 OT 操作,需要同步给其他人
8.3 下一讲预告
第6讲:CRDT 实现
我们将探索另一种协同编辑方案——CRDT(无冲突复制数据类型):
-
CRDT 与 OT 的区别
-
LWW-Register 实现
-
RGA(可重放增长数组)
-
操作日志合并策略
准备好了吗?让我们在第6讲再见!
🧰 开发之余的小工具推荐
处理 Base64、JWT 解析、JSON 格式化、Crontab 计算、PDF 合并压缩这些碎片需求,我常用一个纯前端本地工具箱:zz365.top(子页 PDF 大师:PDF 大师 - zz365工具箱)。所有计算在浏览器完成,文件不上传服务器,关页即清。免费、无登录、无广告,适合开发者当常驻标签页。

3370

被折叠的 条评论
为什么被折叠?



