Skip to content

Commit 8836fae

Browse files
committed
✨ Support dragging document tabs to the Documents panel #18495
1 parent d1aa3f7 commit 8836fae

7 files changed

Lines changed: 271 additions & 20 deletions

File tree

app/src/boot/globalEvent/event.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,10 @@ export const initWindowEvent = (app: App) => {
8484
let scrollTarget: HTMLElement | false;
8585
window.addEventListener("dragstart", clearDragTipGhost, true);
8686
window.addEventListener("dragover", (event: DragEvent & { target: HTMLElement }) => {
87-
if (event.dataTransfer.types.includes(Constants.SIYUAN_DROP_TAB)) {
88-
if (!hasClosestByClassName(event.target, "layout-tab-bar")) {
87+
const isDocumentTab = event.dataTransfer.types.includes(Constants.SIYUAN_DROP_DOCUMENT_TAB);
88+
const tabBarElement = hasClosestByClassName(event.target, "layout-tab-bar");
89+
if (event.dataTransfer.types.includes(Constants.SIYUAN_DROP_TAB) && (!isDocumentTab || tabBarElement)) {
90+
if (!tabBarElement) {
8991
stopScrollAnimation();
9092
}
9193
return;
@@ -102,7 +104,7 @@ export const initWindowEvent = (app: App) => {
102104
if (event.dataTransfer.types.includes("text/plain")) {
103105
return;
104106
}
105-
// 拖拽标题/列表项块标时,按浮窗模型控制文档树所在浮动 dock 的显隐:
107+
// 拖拽文档页签或标题/列表项块标时,按浮窗模型控制文档树所在浮动 dock 的显隐:
106108
// 鼠标在边缘触发区或面板内则展开,离开则收起 https://github.com/siyuan-note/siyuan/issues/18043
107109
if (!isWindow() &&
108110
(!window.siyuan.layout.leftDock.pin || !window.siyuan.layout.rightDock.pin || !window.siyuan.layout.bottomDock.pin)) {
@@ -117,7 +119,7 @@ export const initWindowEvent = (app: App) => {
117119
break;
118120
}
119121
}
120-
if (["nodeheading", "nodelistitem"].includes(gutterBlockType)) {
122+
if (isDocumentTab || ["nodeheading", "nodelistitem"].includes(gutterBlockType)) {
121123
const statusHeight = document.getElementById("status")?.clientHeight || 0;
122124
const toolbarHeight = document.getElementById("toolbar")?.clientHeight || 0;
123125
const inYRange = event.clientY > toolbarHeight && event.clientY < window.innerHeight - statusHeight;
@@ -145,6 +147,10 @@ export const initWindowEvent = (app: App) => {
145147
}
146148
const fileElement = hasClosestByClassName(event.target, "sy__file");
147149
const protyleElement = hasClosestByClassName(event.target, "protyle", true);
150+
if (isDocumentTab && !fileElement) {
151+
stopScrollAnimation();
152+
return;
153+
}
148154
// 光标不在编辑器也不在文档树内时,隐藏拖拽提示(避免卡在无效区域)
149155
if (!fileElement && !protyleElement) {
150156
document.querySelector(".drag-tip")?.remove();

app/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export abstract class Constants {
2828
public static readonly SIYUAN_DROP_GUTTER: string = "application/siyuan-gutter";
2929
public static readonly SIYUAN_DROP_BLOCK_REF: string = "application/siyuan-block-ref";
3030
public static readonly SIYUAN_DROP_TAB: string = "application/siyuan-tab";
31+
public static readonly SIYUAN_DROP_DOCUMENT_TAB: string = "application/siyuan-document-tab";
3132
public static readonly SIYUAN_DROP_EDITOR: string = "application/siyuan-editor";
3233

3334
// 渲染进程调主进程

app/src/layout/Tab.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,20 @@ export class Tab {
5050
const tabElement = hasClosestByTag(event.target, "LI");
5151
if (tabElement) {
5252
event.dataTransfer.setData("text/html", tabElement.outerHTML);
53-
const modeJSON = {id: this.id};
53+
const modeJSON = {id: this.id} as ILayoutJSON & {id: string};
5454
layoutToJSON(this, modeJSON);
5555
event.dataTransfer.setData(Constants.SIYUAN_DROP_TAB, JSON.stringify(modeJSON));
56+
const editorJSON = Array.isArray(modeJSON.children) ? undefined : modeJSON.children;
57+
delete tabElement.dataset.dragDocumentId;
58+
if (editorJSON?.instance === "Editor" && editorJSON.rootId) {
59+
event.dataTransfer.setData(Constants.SIYUAN_DROP_DOCUMENT_TAB, JSON.stringify({
60+
rootId: editorJSON.rootId,
61+
tabId: this.id,
62+
title: this.title,
63+
}));
64+
tabElement.dataset.dragDocumentId = editorJSON.rootId;
65+
window.siyuan.dragTitle = this.title;
66+
}
5667
event.dataTransfer.dropEffect = "move";
5768
tabElement.style.opacity = "0.38";
5869
window.siyuan.dragElement = this.headElement;
@@ -65,6 +76,7 @@ export class Tab {
6576
const tabElement = hasClosestByTag(event.target, "LI");
6677
if (tabElement) {
6778
tabElement.style.opacity = "1";
79+
delete tabElement.dataset.dragDocumentId;
6880
}
6981
/// #if !BROWSER
7082
// 拖拽到屏幕外
@@ -86,16 +98,7 @@ export class Tab {
8698
window.siyuan.dragElement = undefined;
8799
if (event.dataTransfer.dropEffect === "none") {
88100
// 按 esc 取消的时候应该还原在 dragover 时交换的 tab
89-
this.parent.children.forEach((item, index) => {
90-
const currentElement = this.headElement.parentElement.children[index];
91-
if (item.headElement !== currentElement) {
92-
if (index === 0) {
93-
this.headElement.parentElement.firstElementChild.before(item.headElement);
94-
} else {
95-
this.headElement.parentElement.children[index - 1].after(item.headElement);
96-
}
97-
}
98-
});
101+
this.restoreHeadElementOrder();
99102
}
100103
/// #if !BROWSER
101104
ipcRenderer.send(Constants.SIYUAN_SEND_WINDOWS, {cmd: "resetTabsStyle", data: "addRegionStyle"});
@@ -109,6 +112,23 @@ export class Tab {
109112
this.panelElement.setAttribute("data-id", this.id);
110113
}
111114

115+
public restoreHeadElementOrder() {
116+
const headersElement = this.headElement?.parentElement;
117+
if (!headersElement) {
118+
return;
119+
}
120+
this.parent.children.forEach((item, index) => {
121+
const currentElement = headersElement.children[index];
122+
if (item.headElement !== currentElement) {
123+
if (index === 0) {
124+
headersElement.firstElementChild.before(item.headElement);
125+
} else {
126+
headersElement.children[index - 1].after(item.headElement);
127+
}
128+
}
129+
});
130+
}
131+
112132
public updateTitle(title: string) {
113133
this.title = title;
114134
this.headElement.querySelector(".item__text").innerHTML = escapeHtml(title);

app/src/layout/Wnd.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,12 @@ export class Wnd {
319319
this.element.addEventListener("dragenter", (event: DragEvent & { target: HTMLElement }) => {
320320
elementDragCounter++;
321321
if (event.dataTransfer.types.includes(Constants.SIYUAN_DROP_TAB)) {
322+
if (event.dataTransfer.types.includes(Constants.SIYUAN_DROP_DOCUMENT_TAB) &&
323+
hasClosestByClassName(event.target, "sy__file")) {
324+
dragElement.classList.add("fn__none");
325+
dragElement.removeAttribute("style");
326+
return;
327+
}
322328
const tabHeadersElement = hasClosestByClassName(event.target, "layout-tab-bar");
323329
if (tabHeadersElement) {
324330
return;

app/src/layout/dock/Files.ts

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {escapeAriaLabel, escapeHtml, escapeLessThans} from "../../util/escape";
22
import {Tab} from "../Tab";
33
import {Model} from "../Model";
4-
import {setPanelFocus} from "../util";
4+
import {getInstanceById, setPanelFocus} from "../util";
55
import {getDockByType} from "../tabUtil";
66
import {Constants} from "../../constants";
77
import {getDocDisplayName, isMoveTargetAllowed, pathPosix, setNoteBook} from "../../util/pathName";
@@ -42,7 +42,10 @@ import {
4242
collectExpandedDocIDs,
4343
findMovedFileTreeItem,
4444
getFileTreeChildList,
45+
IDocumentTabDragData,
4546
IFileTreeMove,
47+
insertDocumentSortPath,
48+
parseDocumentTabDragData,
4649
restoreMovedExpandedDocItems,
4750
updateMovedSubtree
4851
} from "../../util/fileTreeMove";
@@ -528,7 +531,9 @@ export class Files extends Model {
528531
/// #endif
529532
});
530533
this.element.addEventListener("dragover", (event: DragEvent & { target: HTMLElement }) => {
531-
if (window.siyuan.config.readonly || !window.siyuan.dragElement || event.dataTransfer.types.includes(Constants.SIYUAN_DROP_TAB)) {
534+
const isDocumentTab = event.dataTransfer.types.includes(Constants.SIYUAN_DROP_DOCUMENT_TAB);
535+
if (window.siyuan.config.readonly || !window.siyuan.dragElement ||
536+
(event.dataTransfer.types.includes(Constants.SIYUAN_DROP_TAB) && !isDocumentTab)) {
532537
event.preventDefault();
533538
return;
534539
}
@@ -573,14 +578,19 @@ export class Files extends Model {
573578
event.preventDefault();
574579
return;
575580
}
576-
} else if (liElement.classList.contains("b3-list-item--focus")) {
581+
} else if (isDocumentTab && liElement.dataset.nodeId ===
582+
window.siyuan.dragElement.dataset.dragDocumentId) {
583+
hideDragTip();
584+
event.preventDefault();
585+
return;
586+
} else if (!isDocumentTab && liElement.classList.contains("b3-list-item--focus")) {
577587
// 选中的文档不能拖拽到自己上,但允许标题拖拽到文档树的选中文档上 https://github.com/siyuan-note/siyuan/issues/6552
578588
hideDragTip();
579589
event.preventDefault();
580590
return;
581591
}
582592

583-
dragOverLastObj.sourceOnlyRoot = gutterType ? false : true;
593+
dragOverLastObj.sourceOnlyRoot = gutterType || isDocumentTab ? false : true;
584594
if (dragOverLastObj.sourceOnlyRoot) {
585595
const focusItems = this.element.querySelectorAll(".b3-list-item--focus");
586596
for (let i = 0; i < focusItems.length; i++) {
@@ -665,6 +675,7 @@ export class Files extends Model {
665675
}
666676
}
667677
event.preventDefault();
678+
event.dataTransfer.dropEffect = "move";
668679
});
669680
event.preventDefault();
670681
});
@@ -686,7 +697,11 @@ export class Files extends Model {
686697
counter = 0;
687698
hideDragTip();
688699
window.siyuan.dragTitle = "";
689-
const newElement = this.element.querySelector(".dragover, .dragover__bottom, .dragover__top");
700+
const documentTabData = event.dataTransfer.types.includes(Constants.SIYUAN_DROP_DOCUMENT_TAB) ?
701+
parseDocumentTabDragData(event.dataTransfer.getData(Constants.SIYUAN_DROP_DOCUMENT_TAB)) : undefined;
702+
const sourceTab = documentTabData ? getInstanceById(documentTabData.tabId) as Tab : undefined;
703+
sourceTab?.restoreHeadElementOrder();
704+
const newElement = this.element.querySelector<HTMLElement>(".dragover, .dragover__bottom, .dragover__top");
690705
if (!newElement) {
691706
return;
692707
}
@@ -697,6 +712,13 @@ export class Files extends Model {
697712
const oldScrollTop = this.element.scrollTop;
698713
const toURL = newUlElement.getAttribute("data-url");
699714
const toPath = newElement.getAttribute("data-path");
715+
if (documentTabData) {
716+
event.preventDefault();
717+
event.stopPropagation();
718+
window.siyuan.dragElement = undefined;
719+
await this.dropDocumentTab(documentTabData, newElement, newUlElement, oldScrollTop);
720+
return;
721+
}
700722
let gutterType = "";
701723
for (const item of event.dataTransfer.items) {
702724
if (item.type.startsWith(Constants.SIYUAN_DROP_GUTTER)) {
@@ -899,6 +921,104 @@ export class Files extends Model {
899921
this.init();
900922
}
901923

924+
private async dropDocumentTab(
925+
documentTabData: IDocumentTabDragData,
926+
targetElement: HTMLElement,
927+
notebookElement: HTMLElement,
928+
oldScrollTop: number
929+
) {
930+
const moveAsChild = targetElement.classList.contains("dragover");
931+
const insertAfter = targetElement.classList.contains("dragover__bottom");
932+
const insertBefore = targetElement.classList.contains("dragover__top");
933+
const targetNotebook = notebookElement.getAttribute("data-url");
934+
const targetPath = targetElement.getAttribute("data-path");
935+
targetElement.classList.remove("dragover", "dragover__bottom", "dragover__top");
936+
if (!targetNotebook || !targetPath || (!moveAsChild && !insertAfter && !insertBefore)) {
937+
return;
938+
}
939+
940+
const pathResponse = await fetchSyncPost("/api/filetree/getPathByID", {id: documentTabData.rootId});
941+
if (pathResponse.code !== 0 || !pathResponse.data?.path || !pathResponse.data?.notebook) {
942+
return;
943+
}
944+
const sourcePath = pathResponse.data.path as string;
945+
const sourceNotebook = pathResponse.data.notebook as string;
946+
if (!isMoveTargetAllowed([sourceNotebook], targetNotebook)) {
947+
showMessage(window.siyuan.languages._kernel[313]);
948+
return;
949+
}
950+
if (sourceNotebook === targetNotebook) {
951+
const sourceDirectory = sourcePath.endsWith(".sy") ? sourcePath.slice(0, -3) : sourcePath;
952+
if (targetPath === sourcePath || targetPath.startsWith(sourceDirectory + "/")) {
953+
return;
954+
}
955+
}
956+
957+
if (moveAsChild) {
958+
const sourceParentDirectory = pathPosix().dirname(sourcePath);
959+
const sourceParentPath = sourceParentDirectory === "/" ? "/" : sourceParentDirectory + ".sy";
960+
if (sourceNotebook === targetNotebook && sourceParentPath === targetPath) {
961+
return;
962+
}
963+
await fetchSyncPost("/api/filetree/moveDocs", {
964+
toNotebook: targetNotebook,
965+
fromPaths: [sourcePath],
966+
toPath: targetPath,
967+
});
968+
return;
969+
}
970+
971+
const notebookSort = notebookElement.getAttribute("data-sortmode");
972+
if (notebookSort !== "6" && !(window.siyuan.config.fileTree.sort === 6 && notebookSort === "15")) {
973+
return;
974+
}
975+
const targetDirectory = pathPosix().dirname(targetPath);
976+
const newPath = pathPosix().join(targetDirectory, documentTabData.rootId + ".sy");
977+
const siblingPaths: string[] = [];
978+
Array.from(targetElement.parentElement.children).forEach((item) => {
979+
const path = item.getAttribute("data-path");
980+
if (item.tagName === "LI" && path) {
981+
siblingPaths.push(path);
982+
}
983+
});
984+
const sortedPaths = insertDocumentSortPath(
985+
siblingPaths,
986+
documentTabData.rootId,
987+
newPath,
988+
targetPath,
989+
insertAfter
990+
);
991+
if (!sortedPaths || (sortedPaths.length === siblingPaths.length &&
992+
sortedPaths.every((path, index) => path === siblingPaths[index]))) {
993+
return;
994+
}
995+
const targetParentPath = targetDirectory === "/" ? "/" : targetDirectory + ".sy";
996+
const moveResponse = await fetchSyncPost("/api/filetree/moveDocs", {
997+
toNotebook: targetNotebook,
998+
fromPaths: [sourcePath],
999+
toPath: targetParentPath,
1000+
callback: Constants.CB_MOVE_NOLIST,
1001+
});
1002+
if (moveResponse.code !== 0) {
1003+
return;
1004+
}
1005+
const sortResponse = await fetchSyncPost("/api/filetree/changeSort", {
1006+
paths: sortedPaths,
1007+
notebook: targetNotebook,
1008+
});
1009+
if (sortResponse.code !== 0) {
1010+
return;
1011+
}
1012+
const listResponse = await fetchSyncPost("/api/filetree/listDocsByPath", {
1013+
notebook: targetNotebook,
1014+
path: targetParentPath,
1015+
app: Constants.SIYUAN_APPID,
1016+
});
1017+
if (listResponse.code === 0 && listResponse.data?.files?.length > 0) {
1018+
this.onLsHTML(listResponse.data, oldScrollTop);
1019+
}
1020+
}
1021+
9021022
private handleMsgCallback(data: IWebSocketData) {
9031023
if (data) {
9041024
switch (data.cmd) {

app/src/util/fileTreeMove.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,67 @@ import * as assert from "node:assert/strict";
33
import {
44
findMovedFileTreeItem,
55
IFileTreeMove,
6+
insertDocumentSortPath,
7+
parseDocumentTabDragData,
68
remapMovedPath,
79
restoreMovedExpandedDocItems,
810
updateMovedSubtree
911
} from "./fileTreeMove";
1012

13+
describe("parseDocumentTabDragData", () => {
14+
it("parses a document tab payload", () => {
15+
assert.deepEqual(parseDocumentTabDragData(JSON.stringify({
16+
rootId: "20260802120000-abcdefg",
17+
tabId: "tab-id",
18+
title: "Document",
19+
})), {
20+
rootId: "20260802120000-abcdefg",
21+
tabId: "tab-id",
22+
title: "Document",
23+
});
24+
});
25+
26+
it("rejects non-document payloads", () => {
27+
assert.equal(parseDocumentTabDragData(JSON.stringify({
28+
rootId: "invalid",
29+
tabId: "tab-id",
30+
title: "Document",
31+
})), undefined);
32+
});
33+
});
34+
35+
describe("insertDocumentSortPath", () => {
36+
it("inserts a document from another parent before the target", () => {
37+
assert.deepEqual(insertDocumentSortPath(
38+
["/target-a.sy", "/target-b.sy"],
39+
"20260802120000-abcdefg",
40+
"/20260802120000-abcdefg.sy",
41+
"/target-b.sy",
42+
false
43+
), ["/target-a.sy", "/20260802120000-abcdefg.sy", "/target-b.sy"]);
44+
});
45+
46+
it("reorders a document already under the target parent", () => {
47+
assert.deepEqual(insertDocumentSortPath(
48+
["/a.sy", "/20260802120000-abcdefg.sy", "/b.sy"],
49+
"20260802120000-abcdefg",
50+
"/20260802120000-abcdefg.sy",
51+
"/b.sy",
52+
true
53+
), ["/a.sy", "/b.sy", "/20260802120000-abcdefg.sy"]);
54+
});
55+
56+
it("returns undefined when the target is missing", () => {
57+
assert.equal(insertDocumentSortPath(
58+
["/a.sy"],
59+
"20260802120000-abcdefg",
60+
"/20260802120000-abcdefg.sy",
61+
"/missing.sy",
62+
false
63+
), undefined);
64+
});
65+
});
66+
1167
const move: IFileTreeMove = {
1268
fromNotebook: "source-notebook",
1369
fromPath: "/parent/current.sy",

0 commit comments

Comments
 (0)