Skip to content

Commit a264caa

Browse files
committed
🎨 Improve Cut and Paste #17850
Signed-off-by: Daniel <845765@qq.com>
1 parent 57819eb commit a264caa

6 files changed

Lines changed: 130 additions & 104 deletions

File tree

app/src/protyle/undo/globalUndo.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,16 +212,16 @@ export const requestUndo = async (protyle: IProtyle) => {
212212
markMirror(rootID, {canUndo: !!data.canUndo, canRedo: !!data.canRedo});
213213
const mutatedRootIDs: string[] = data.mutatedRootIDs || [];
214214
if (mutatedRootIDs.length > 1) {
215-
// 跨文档撤销:undoOperations 的锚点分散在多个文档,当前 protyle 无法本地乐观应用。
215+
// 跨文档撤销:doOperations 的锚点分散在多个文档,当前 protyle 无法本地乐观应用。
216216
// 改为靠 kernel 广播(含发起方)刷新所有涉及文档的 DOM。
217217
// 这里不调 renderLocal,避免在错误 protyle 上应用跨文档 move 导致前后端不一致。
218218
refreshUndoButtons(protyle);
219219
// 广播会到达当前窗口(/undo 对跨文档用 PushModeBroadcast),触发 onTransaction 刷新 DOM
220220
} else {
221-
// 单文档撤销:发起窗口本地乐观应用(isUndo=true,保光标/折叠/zoom
222-
protyle.undo.renderLocal(protyle, data.undoOperations, false);
221+
// 单文档撤销:发起窗口本地乐观应用 doOperations(kernel 实际执行的操作,如 insert 恢复块
222+
protyle.undo.renderLocal(protyle, data.doOperations, false);
223223
refreshUndoButtons(protyle);
224-
const focusBlockId = data.undoOperations?.find((op: IOperation) => op.action === "insert")?.id;
224+
const focusBlockId = data.doOperations?.find((op: IOperation) => op.action === "insert")?.id;
225225
focusRootIDs(mutatedRootIDs, focusBlockId);
226226
}
227227
});

app/src/protyle/undo/index.ts

Lines changed: 0 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {Constants} from "../../constants";
44
import {hideElements} from "../ui/hideElements";
55
import {scrollCenter} from "../../util/highlightById";
66
import {matchHotKey} from "../util/hotKey";
7-
import {fetchSyncPost} from "../../util/fetch";
87
import {ipcRenderer} from "electron";
98
import {markMirror, refreshUndoButtons, requestRedo, requestUndo} from "./globalUndo";
109

@@ -27,96 +26,6 @@ export class Undo {
2726
requestRedo(protyle);
2827
}
2928

30-
31-
// 重放 insert 操作前检查块 ID 是否已被占用(例如剪切后粘贴到其他编辑器时保留了原 ID,
32-
// 此时撤销剪切会插入重复 ID 的块),冲突时在两个栈中统一替换为新 ID
33-
private async resolveDuplicateIds(operations: IOperation[]) {
34-
const ids = new Set<string>();
35-
operations.forEach(op => {
36-
if (op.action === "insert" && typeof op.data === "string") {
37-
if (op.id) {
38-
ids.add(op.id);
39-
}
40-
op.data.match(/data-node-id="[^"]+"/g)?.forEach((match: string) => {
41-
ids.add(match.substring(14, match.length - 1));
42-
});
43-
}
44-
});
45-
if (ids.size === 0) {
46-
return;
47-
}
48-
let existResponse: IWebSocketData;
49-
try {
50-
existResponse = await fetchSyncPost("/api/block/checkBlocksExist", {ids: Array.from(ids)});
51-
} catch (e) {
52-
return;
53-
}
54-
if (!existResponse?.data) {
55-
return;
56-
}
57-
const replacements: [string, string][] = [];
58-
ids.forEach(id => {
59-
if (existResponse.data[id] === true) {
60-
replacements.push([id, Lute.NewNodeID()]);
61-
}
62-
});
63-
if (replacements.length === 0) {
64-
return;
65-
}
66-
[this.undoStack, this.redoStack].forEach(stack => {
67-
stack.forEach(item => {
68-
[item.doOperations, item.undoOperations].forEach(ops => {
69-
ops.forEach(op => {
70-
replacements.forEach(([oldId, newId]) => {
71-
if (op.id === oldId) {
72-
op.id = newId;
73-
}
74-
if (op.parentID === oldId) {
75-
op.parentID = newId;
76-
}
77-
if (op.previousID === oldId) {
78-
op.previousID = newId;
79-
}
80-
if (op.nextID === oldId) {
81-
op.nextID = newId;
82-
}
83-
if (typeof op.data === "string") {
84-
op.data = op.data.split(`data-node-id="${oldId}"`).join(`data-node-id="${newId}"`);
85-
}
86-
});
87-
});
88-
});
89-
});
90-
});
91-
}
92-
93-
private async render(protyle: IProtyle, state: IOperations, redo: boolean) {
94-
hideElements(["hint", "gutter"], protyle);
95-
protyle.wysiwyg.lastHTMLs = {};
96-
await this.resolveDuplicateIds(redo ? state.doOperations : state.undoOperations);
97-
if (!redo) {
98-
for (let i = state.undoOperations.length - 1; i >= 0; i--) {
99-
if (state.undoOperations[i].action === "insert") {
100-
if (state.undoOperations[i].context) {
101-
state.undoOperations[i].context.setRange = "true";
102-
} else {
103-
state.undoOperations[i].context = {setRange: "true"};
104-
}
105-
break;
106-
}
107-
}
108-
onTransaction(protyle, state.undoOperations, true);
109-
transaction(protyle, state.undoOperations, undefined, {skipSync: true});
110-
} else {
111-
for (let i = state.doOperations.length - 1; i >= 0; i--) {
112-
if (state.doOperations[i].action === "insert") {
113-
if (state.doOperations[i].context) {
114-
state.doOperations[i].context.setRange = "true";
115-
} else {
116-
state.doOperations[i].context = {setRange: "true"};
117-
}
118-
break;
119-
12029
// renderLocal 仅在发起窗口本地应用操作(isUndo=true),不 POST 到 kernel
12130
// (kernel 的 undo/redo 接口已执行事务并广播)。保留光标恢复/折叠/zoom/lastHTMLs 行为。
12231
public renderLocal(protyle: IProtyle, operations: IOperation[], isRedo: boolean) {

app/src/protyle/util/clear.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import {updateHeader} from "../render/av/row";
22
import {Constants} from "../../constants";
33

4-
export const clearBlockElement = (element: Element) => {
4+
export const clearBlockElement = (element: Element, keepRefcount = false) => {
55
element.classList.remove("protyle-wysiwyg--select", "protyle-wysiwyg--hl");
66
element.removeAttribute(Constants.CUSTOM_RIFF_DECKS);
7-
element.removeAttribute("refcount");
8-
element.querySelector(".protyle-attr--refcount")?.remove();
7+
if (!keepRefcount) {
8+
element.removeAttribute("refcount");
9+
element.querySelector(".protyle-attr--refcount")?.remove();
10+
}
911
element.querySelector(".protyle-attr--av")?.remove();
1012
element.removeAttribute("custom-avs");
1113
element.getAttributeNames().forEach(attr => {

app/src/protyle/util/paste.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -493,10 +493,13 @@ export const paste = async (protyle: IProtyle, event: (ClipboardEvent | DragEven
493493
});
494494
const existResponse = await fetchSyncPost("/api/block/checkBlocksExist", {ids: oldIds});
495495
pastedBlockElements.forEach((e) => {
496-
if (existResponse.data[e.getAttribute("data-node-id")] !== false) {
496+
const originalId = e.getAttribute("data-node-id");
497+
const isCutPaste = existResponse.data[originalId] === false; // 剪切来的(原块已删)
498+
if (!isCutPaste) {
499+
// 复制粘贴:生成新 ID
497500
e.setAttribute("data-node-id", Lute.NewNodeID());
498501
}
499-
clearBlockElement(e);
502+
clearBlockElement(e, isCutPaste); // 剪切粘贴保留引用角标
500503
});
501504
}
502505
if (nodeElement.classList.contains("table")) {

kernel/api/transaction.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@ func performUndo(c *gin.Context) {
202202
UndoOperations: entry.DoOperationsForReplay(),
203203
}
204204
tx.MarkReplay()
205+
// 重放前解决剪切后粘贴造成的块 ID 冲突(已存在的 ID 换新,避免重复)
206+
model.ResolveReplayDuplicateIds(tx)
205207

206208
if err := model.PerformTxSync(tx); nil != err {
207209
// 逆操作执行失败,回滚执行栈。返回 code=0 + data.failed=true(而非 code=-1),
@@ -221,9 +223,10 @@ func performUndo(c *gin.Context) {
221223
pushUndoTransactions(app, session, []*model.Transaction{tx}, true, crossDoc)
222224

223225
canUndo, canRedo, _ := model.GlobalUndoLog.State(rootID)
226+
// 返回重放后(已解决 ID 冲突)的 tx 操作,前端乐观应用与 kernel 落盘一致
224227
ret.Data = map[string]any{
225-
"doOperations": entry.DoOperationsForReplay(),
226-
"undoOperations": entry.UndoOperationsForReplay(),
228+
"doOperations": tx.DoOperations,
229+
"undoOperations": tx.UndoOperations,
227230
"mutatedRootIDs": entry.MutatedRootIDs(),
228231
"canUndo": canUndo,
229232
"canRedo": canRedo,
@@ -265,6 +268,8 @@ func performRedo(c *gin.Context) {
265268
UndoOperations: entry.UndoOperationsForReplay(),
266269
}
267270
tx.MarkReplay()
271+
// 重放前解决剪切后粘贴造成的块 ID 冲突(已存在的 ID 换新,避免重复)
272+
model.ResolveReplayDuplicateIds(tx)
268273

269274
if err := model.PerformTxSync(tx); nil != err {
270275
// 重做失败,回滚执行栈。返回 code=0 + data.failed=true(避免前端 isUndoing 死锁)。
@@ -283,9 +288,10 @@ func performRedo(c *gin.Context) {
283288
pushUndoTransactions(app, session, []*model.Transaction{tx}, true, crossDoc)
284289

285290
canUndo, canRedo, _ := model.GlobalUndoLog.State(rootID)
291+
// 返回重放后(已解决 ID 冲突)的 tx 操作,前端乐观应用与 kernel 落盘一致
286292
ret.Data = map[string]any{
287-
"doOperations": entry.DoOperationsForReplay(),
288-
"undoOperations": entry.UndoOperationsForReplay(),
293+
"doOperations": tx.DoOperations,
294+
"undoOperations": tx.UndoOperations,
289295
"mutatedRootIDs": entry.MutatedRootIDs(),
290296
"canUndo": canUndo,
291297
"canRedo": canRedo,

kernel/model/undolog.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@ package model
1818

1919
import (
2020
"fmt"
21+
"regexp"
2122
"sync"
2223
"sync/atomic"
2324
"time"
2425

2526
"github.com/88250/gulu"
27+
"github.com/88250/lute/ast"
28+
"github.com/siyuan-note/siyuan/kernel/treenode"
2629
)
2730

2831
// UndoEntry 是撤销栈中的一条记录。跨文档操作(MutatedRootIDs 含多个 rootID)的 entry
@@ -363,3 +366,106 @@ func cloneOperations(ops []*Operation) []*Operation {
363366
}
364367
return ret
365368
}
369+
370+
var dataNodeIDPattern = regexp.MustCompile(`data-node-id="([^"]+)"`)
371+
var refcountAttrPattern = regexp.MustCompile(`\s*refcount="[^"]*"`)
372+
var refcountDivPattern = regexp.MustCompile(`<div class="protyle-attr--refcount[^"]*"[^>]*>.*?</div>`)
373+
374+
// ResolveReplayDuplicateIds 在 undo/redo 重放事务前解决块 ID 冲突。
375+
// 场景:剪切块 X 后粘贴到别处(保留原 ID),再撤销剪切会 insert X,而 X 已存在于粘贴处,产生重复 ID。
376+
// 这里对即将重放的 insert 操作做检查——若其引入的 ID 在块树中已存在,则在正反向操作及关联字段
377+
// (ID/ParentID/PreviousID/NextID 与 Data 内联 ID)上统一替换为新 ID。
378+
// 替换同时作用于 do/undo 两套操作:重放只执行 doOperations,但若不同步改 undoOperations,
379+
// 随后对同一 entry 的 redo 会沿用旧 ID 再次撞库。
380+
func ResolveReplayDuplicateIds(tx *Transaction) {
381+
if nil == tx || !tx.isReplay {
382+
return
383+
}
384+
385+
// 收集所有 insert 操作引入的块 ID(op.ID + Data 内联的 data-node-id)
386+
ids := map[string]struct{}{}
387+
collect := func(ops []*Operation) {
388+
for _, op := range ops {
389+
if "insert" != op.Action {
390+
continue
391+
}
392+
if "" != op.ID && ast.IsNodeIDPattern(op.ID) {
393+
ids[op.ID] = struct{}{}
394+
}
395+
data, ok := op.Data.(string)
396+
if !ok {
397+
continue
398+
}
399+
for _, m := range dataNodeIDPattern.FindAllStringSubmatch(data, -1) {
400+
if ast.IsNodeIDPattern(m[1]) {
401+
ids[m[1]] = struct{}{}
402+
}
403+
}
404+
}
405+
}
406+
collect(tx.DoOperations)
407+
// 注意:只检测 DoOperations(实际执行的操作),不检测 UndoOperations。
408+
// UndoOperations 在 redo 时会作为新的 DoOperations 再次过 ResolveReplayDuplicateIds。
409+
// 若 undo 时也检测 UndoOperations 的 insert,会把 redo 用的 ID 换新,
410+
// 污染 DoOperations 的对应 delete(do/undo 共享 replacements),导致撤销删除错误 ID。
411+
if 0 == len(ids) {
412+
return
413+
}
414+
415+
idList := make([]string, 0, len(ids))
416+
for id := range ids {
417+
idList = append(idList, id)
418+
}
419+
exist := treenode.ExistBlockTrees(idList)
420+
421+
// 已存在的 ID 生成替换
422+
replacements := map[string]string{}
423+
for _, id := range idList {
424+
if exist[id] {
425+
replacements[id] = ast.NewNodeID()
426+
}
427+
}
428+
if 0 == len(replacements) {
429+
return
430+
}
431+
432+
// 对 do/undo 两套操作统一替换 ID 及关联字段
433+
apply := func(ops []*Operation) {
434+
for _, op := range ops {
435+
// 记录本操作的 ID 是否被换新(在改 op.ID 之前判断,否则 replacements 的 key 是 oldID 查不到)
436+
_, idReplaced := replacements[op.ID]
437+
if newID, ok := replacements[op.ID]; ok {
438+
op.ID = newID
439+
}
440+
if newID, ok := replacements[op.ParentID]; ok {
441+
op.ParentID = newID
442+
}
443+
if newID, ok := replacements[op.PreviousID]; ok {
444+
op.PreviousID = newID
445+
}
446+
if newID, ok := replacements[op.NextID]; ok {
447+
op.NextID = newID
448+
}
449+
data, ok := op.Data.(string)
450+
if !ok {
451+
continue
452+
}
453+
for oldID, newID := range replacements {
454+
data = dataNodeIDPattern.ReplaceAllStringFunc(data, func(match string) string {
455+
if sub := dataNodeIDPattern.FindStringSubmatch(match); len(sub) > 1 && sub[1] == oldID {
456+
return `data-node-id="` + newID + `"`
457+
}
458+
return match
459+
})
460+
}
461+
// ID 被换新的块(剪切粘贴后撤销恢复的副本)清除引用角标,避免显示旧的 refcount。
462+
// 角标由 kernel 异步刷新(refreshRefCount)重建为正确值。
463+
if idReplaced {
464+
data = refcountDivPattern.ReplaceAllString(data, "")
465+
data = refcountAttrPattern.ReplaceAllString(data, "")
466+
}
467+
op.Data = data
468+
}
469+
}
470+
apply(tx.DoOperations)
471+
}

0 commit comments

Comments
 (0)