Skip to content

Commit 57819eb

Browse files
zxkmm88250
andauthored
🎨 Improve Cut and Paste (#17850)
* attemp 1 * use EcistBlockTrees * fix the edge case that cut in A, paste in B, undo in a and only done at the second time --------- Co-authored-by: D <845765@qq.com>
1 parent e06a316 commit 57819eb

4 files changed

Lines changed: 130 additions & 6 deletions

File tree

app/src/protyle/undo/index.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ 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";
78
import {ipcRenderer} from "electron";
89
import {markMirror, refreshUndoButtons, requestRedo, requestUndo} from "./globalUndo";
910

@@ -26,6 +27,96 @@ export class Undo {
2627
requestRedo(protyle);
2728
}
2829

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+
29120
// renderLocal 仅在发起窗口本地应用操作(isUndo=true),不 POST 到 kernel
30121
// (kernel 的 undo/redo 接口已执行事务并广播)。保留光标恢复/折叠/zoom/lastHTMLs 行为。
31122
public renderLocal(protyle: IProtyle, operations: IOperation[], isRedo: boolean) {
@@ -37,6 +128,7 @@ export class Undo {
37128
operations[i].context.setRange = "true";
38129
} else {
39130
operations[i].context = {setRange: "true"};
131+
40132
}
41133
break;
42134
}

app/src/protyle/util/paste.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {hasClosestBlock, hasClosestByAttribute, hasClosestByClassName} from "./h
66
import {getEditorRange, getSelectionOffset} from "./selection";
77
import {blockRender} from "../render/blockRender";
88
import {highlightRender} from "../render/highlightRender";
9-
import {fetchPost} from "../../util/fetch";
9+
import {fetchPost, fetchSyncPost} from "../../util/fetch";
1010
import {isDynamicRef, isFileAnnotation} from "../../util/functions";
1111
import {insertHTML} from "./insertHTML";
1212
import {scrollCenter} from "../../util/highlightById";
@@ -482,12 +482,23 @@ export const paste = async (protyle: IProtyle, event: (ClipboardEvent | DragEven
482482
}
483483
}
484484
let isBlock = false;
485-
tempElement.querySelectorAll("[data-node-id]").forEach((e) => {
486-
const newId = Lute.NewNodeID();
487-
e.setAttribute("data-node-id", newId);
488-
clearBlockElement(e);
485+
const pastedBlockElements = tempElement.querySelectorAll("[data-node-id]");
486+
if (pastedBlockElements.length > 0) {
489487
isBlock = true;
490-
});
488+
// 剪切后粘贴时原块已被删除,保留原 ID 可避免该块被其他位置的引用失效;
489+
// 仅当 ID 仍存在(复制粘贴)时才生成新 ID
490+
const oldIds: string[] = [];
491+
pastedBlockElements.forEach((e) => {
492+
oldIds.push(e.getAttribute("data-node-id"));
493+
});
494+
const existResponse = await fetchSyncPost("/api/block/checkBlocksExist", {ids: oldIds});
495+
pastedBlockElements.forEach((e) => {
496+
if (existResponse.data[e.getAttribute("data-node-id")] !== false) {
497+
e.setAttribute("data-node-id", Lute.NewNodeID());
498+
}
499+
clearBlockElement(e);
500+
});
501+
}
491502
if (nodeElement.classList.contains("table")) {
492503
isBlock = false;
493504
}

kernel/api/block.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"strings"
2424

2525
"github.com/88250/gulu"
26+
"github.com/88250/lute/ast"
2627
"github.com/88250/lute/html"
2728
"github.com/gin-gonic/gin"
2829
"github.com/siyuan-note/logging"
@@ -376,6 +377,25 @@ func checkBlockExist(c *gin.Context) {
376377
ret.Data = treenode.ExistBlockTree(id)
377378
}
378379

380+
func checkBlocksExist(c *gin.Context) {
381+
ret := gulu.Ret.NewResult()
382+
defer c.JSON(http.StatusOK, ret)
383+
384+
arg, ok := util.JsonArg(c, ret)
385+
if !ok {
386+
return
387+
}
388+
389+
idsArg := arg["ids"].([]interface{})
390+
var ids []string
391+
for _, idArg := range idsArg {
392+
if id, idOk := idArg.(string); idOk && ast.IsNodeIDPattern(id) {
393+
ids = append(ids, id)
394+
}
395+
}
396+
ret.Data = treenode.ExistBlockTrees(ids)
397+
}
398+
379399
func getDocInfo(c *gin.Context) {
380400
ret := gulu.Ret.NewResult()
381401
defer c.JSON(http.StatusOK, ret)

kernel/api/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ func ServeAPI(ginServer *gin.Engine) {
228228
ginServer.Handle("POST", "/api/block/getDocInfo", model.CheckAuth, getDocInfo)
229229
ginServer.Handle("POST", "/api/block/getDocsInfo", model.CheckAuth, getDocsInfo)
230230
ginServer.Handle("POST", "/api/block/checkBlockExist", model.CheckAuth, checkBlockExist)
231+
ginServer.Handle("POST", "/api/block/checkBlocksExist", model.CheckAuth, checkBlocksExist)
231232
ginServer.Handle("POST", "/api/block/getUnfoldedParentID", model.CheckAuth, getUnfoldedParentID)
232233
ginServer.Handle("POST", "/api/block/checkBlockFold", model.CheckAuth, checkBlockFold)
233234
ginServer.Handle("POST", "/api/block/insertBlock", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, insertBlock)

0 commit comments

Comments
 (0)