From ca41244188f2a5fe38553cf1ca55ac3fb2720595 Mon Sep 17 00:00:00 2001
From: Jeffrey Chen <78434827+TCOTC@users.noreply.github.com>
Date: Mon, 16 Feb 2026 11:26:13 +0800
Subject: [PATCH] :art: Support copying file in the asset menu on Windows and
macOS (#17049)
---
app/appearance/langs/ar_SA.json | 1 +
app/appearance/langs/de_DE.json | 1 +
app/appearance/langs/en_US.json | 1 +
app/appearance/langs/es_ES.json | 1 +
app/appearance/langs/fr_FR.json | 1 +
app/appearance/langs/he_IL.json | 1 +
app/appearance/langs/it_IT.json | 1 +
app/appearance/langs/ja_JP.json | 1 +
app/appearance/langs/ko_KR.json | 1 +
app/appearance/langs/pl_PL.json | 1 +
app/appearance/langs/pt_BR.json | 1 +
app/appearance/langs/ru_RU.json | 1 +
app/appearance/langs/tr_TR.json | 1 +
app/appearance/langs/zh_CHT.json | 1 +
app/appearance/langs/zh_CN.json | 1 +
app/src/menus/protyle.ts | 17 +++-
app/src/menus/util.ts | 22 +++++
kernel/api/clipboard.go | 39 +++++++-
kernel/api/router.go | 1 +
kernel/go.mod | 1 +
kernel/go.sum | 2 +
kernel/util/clipboard.go | 26 +++++
kernel/util/clipboard_darwin.go | 104 ++++++++++++++++++++
kernel/util/clipboard_windows.go | 162 +++++++++++++++++++++++++++++++
24 files changed, 387 insertions(+), 2 deletions(-)
create mode 100644 kernel/util/clipboard.go
create mode 100644 kernel/util/clipboard_darwin.go
create mode 100644 kernel/util/clipboard_windows.go
diff --git a/app/appearance/langs/ar_SA.json b/app/appearance/langs/ar_SA.json
index 59ed86af4..8a531137f 100644
--- a/app/appearance/langs/ar_SA.json
+++ b/app/appearance/langs/ar_SA.json
@@ -1333,6 +1333,7 @@
"column": "العمود",
"copied": "تم النسخ",
"copy": "نسخ",
+ "copyFile": "نسخ الملف",
"copyText": "نسخ النص *",
"delete-column": "حذف العمود",
"delete-row": "حذف الصف",
diff --git a/app/appearance/langs/de_DE.json b/app/appearance/langs/de_DE.json
index 40981418c..32a8b9ef9 100644
--- a/app/appearance/langs/de_DE.json
+++ b/app/appearance/langs/de_DE.json
@@ -1333,6 +1333,7 @@
"column": "Spalte",
"copied": "Kopiert",
"copy": "Kopieren",
+ "copyFile": "Datei kopieren",
"copyText": "Text kopieren *",
"delete-column": "Spalte löschen",
"delete-row": "Zeile löschen",
diff --git a/app/appearance/langs/en_US.json b/app/appearance/langs/en_US.json
index 31baa7e1d..1c40f0227 100644
--- a/app/appearance/langs/en_US.json
+++ b/app/appearance/langs/en_US.json
@@ -1333,6 +1333,7 @@
"column": "Column",
"copied": "Copied",
"copy": "Copy",
+ "copyFile": "Copy file",
"copyText": "Copy text *",
"delete-column": "Delete Column",
"delete-row": "Delete Row",
diff --git a/app/appearance/langs/es_ES.json b/app/appearance/langs/es_ES.json
index 05dcd12aa..8da1dce79 100644
--- a/app/appearance/langs/es_ES.json
+++ b/app/appearance/langs/es_ES.json
@@ -1333,6 +1333,7 @@
"column": "Columna",
"copied": "Copiado",
"copy": "Copiar",
+ "copyFile": "Copiar archivo",
"copyText": "Copiar texto *",
"delete-column": "Borrar columna",
"delete-row": "Borrar fila",
diff --git a/app/appearance/langs/fr_FR.json b/app/appearance/langs/fr_FR.json
index c1f0a66d2..9c3031ab1 100644
--- a/app/appearance/langs/fr_FR.json
+++ b/app/appearance/langs/fr_FR.json
@@ -1333,6 +1333,7 @@
"column": "Colonne",
"copied": "Copié",
"copy": "Copie",
+ "copyFile": "Copier le fichier",
"copyText": "Copier le texte *",
"delete-column": "Supprimer une Colonne",
"delete-row": "Supprimer la ligne",
diff --git a/app/appearance/langs/he_IL.json b/app/appearance/langs/he_IL.json
index 8639a9f85..8cdebabbf 100644
--- a/app/appearance/langs/he_IL.json
+++ b/app/appearance/langs/he_IL.json
@@ -1333,6 +1333,7 @@
"column": "עמודה",
"copied": "הועתק",
"copy": "העתק",
+ "copyFile": "העתק קובץ",
"copyText": "העתק טקסט *",
"delete-column": "מחק עמודה",
"delete-row": "מחק שורה",
diff --git a/app/appearance/langs/it_IT.json b/app/appearance/langs/it_IT.json
index d19dc3098..8dcde28f1 100644
--- a/app/appearance/langs/it_IT.json
+++ b/app/appearance/langs/it_IT.json
@@ -1333,6 +1333,7 @@
"column": "Colonna",
"copied": "Copiato",
"copy": "Copia",
+ "copyFile": "Copia file",
"copyText": "Copia testo *",
"delete-column": "Elimina colonna",
"delete-row": "Elimina riga",
diff --git a/app/appearance/langs/ja_JP.json b/app/appearance/langs/ja_JP.json
index 9f79a9a68..6c860d7fb 100644
--- a/app/appearance/langs/ja_JP.json
+++ b/app/appearance/langs/ja_JP.json
@@ -1333,6 +1333,7 @@
"column": "列",
"copied": "コピーしました",
"copy": "コピー",
+ "copyFile": "ファイルをコピー",
"copyText": "テキストをコピー *",
"delete-column": "列を削除",
"delete-row": "行を削除",
diff --git a/app/appearance/langs/ko_KR.json b/app/appearance/langs/ko_KR.json
index 53d3815cd..d14627044 100644
--- a/app/appearance/langs/ko_KR.json
+++ b/app/appearance/langs/ko_KR.json
@@ -1333,6 +1333,7 @@
"column": "열",
"copied": "복사됨",
"copy": "복사",
+ "copyFile": "파일 복사",
"copyText": "텍스트 복사 *",
"delete-column": "열 삭제",
"delete-row": "행 삭제",
diff --git a/app/appearance/langs/pl_PL.json b/app/appearance/langs/pl_PL.json
index 6fd96f5dd..a8748832d 100644
--- a/app/appearance/langs/pl_PL.json
+++ b/app/appearance/langs/pl_PL.json
@@ -1333,6 +1333,7 @@
"column": "Kolumna",
"copied": "Skopiowane",
"copy": "Kopiuj",
+ "copyFile": "Kopiuj plik",
"copyText": "Skopiuj tekst *",
"delete-column": "Usuń kolumnę",
"delete-row": "Usuń wiersz",
diff --git a/app/appearance/langs/pt_BR.json b/app/appearance/langs/pt_BR.json
index e471ed4d2..bb7884f1e 100644
--- a/app/appearance/langs/pt_BR.json
+++ b/app/appearance/langs/pt_BR.json
@@ -1333,6 +1333,7 @@
"column": "Coluna",
"copied": "Copiado",
"copy": "Copiar",
+ "copyFile": "Copiar arquivo",
"copyText": "Copiar texto *",
"delete-column": "Excluir Coluna",
"delete-row": "Excluir Linha",
diff --git a/app/appearance/langs/ru_RU.json b/app/appearance/langs/ru_RU.json
index 9c859ff43..fa3067dd8 100644
--- a/app/appearance/langs/ru_RU.json
+++ b/app/appearance/langs/ru_RU.json
@@ -1333,6 +1333,7 @@
"column": "Столбец",
"copied": "Скопировано",
"copy": "Копировать",
+ "copyFile": "Копировать файл",
"copyText": "Копировать текст *",
"delete-column": "Удалить столбец",
"delete-row": "Удалить строку",
diff --git a/app/appearance/langs/tr_TR.json b/app/appearance/langs/tr_TR.json
index 24c942ce5..6a689cc17 100644
--- a/app/appearance/langs/tr_TR.json
+++ b/app/appearance/langs/tr_TR.json
@@ -1333,6 +1333,7 @@
"column": "Sütun",
"copied": "Kopyalandı",
"copy": "Kopyala",
+ "copyFile": "Dosyayı kopyala",
"copyText": "Metni kopyala *",
"delete-column": "Sütunu sil",
"delete-row": "Satırı sil",
diff --git a/app/appearance/langs/zh_CHT.json b/app/appearance/langs/zh_CHT.json
index 8e64f3276..4d9bf96fe 100644
--- a/app/appearance/langs/zh_CHT.json
+++ b/app/appearance/langs/zh_CHT.json
@@ -1333,6 +1333,7 @@
"column": "行",
"copied": "已複製",
"copy": "複製",
+ "copyFile": "複製檔案",
"copyText": "複製 文本 *",
"delete-column": "刪除行",
"delete-row": "刪除列",
diff --git a/app/appearance/langs/zh_CN.json b/app/appearance/langs/zh_CN.json
index 163ed92d4..69252b378 100644
--- a/app/appearance/langs/zh_CN.json
+++ b/app/appearance/langs/zh_CN.json
@@ -1333,6 +1333,7 @@
"column": "列",
"copied": "已复制",
"copy": "复制",
+ "copyFile": "复制文件",
"copyText": "复制 文本 *",
"delete-column": "删除列",
"delete-row": "删除行",
diff --git a/app/src/menus/protyle.ts b/app/src/menus/protyle.ts
index fcaa31dca..3e5528111 100644
--- a/app/src/menus/protyle.ts
+++ b/app/src/menus/protyle.ts
@@ -44,7 +44,7 @@ import {blockRender} from "../protyle/render/blockRender";
import {renameAsset} from "../editor/rename";
import {electronUndo} from "../protyle/undo";
import {pushBack} from "../mobile/util/MobileBackFoward";
-import {copyPNGByLink, exportAsset} from "./util";
+import {copyAsset, copyPNGByLink, exportAsset} from "./util";
import {removeInlineType} from "../protyle/toolbar/util";
import {alignImgCenter, alignImgLeft} from "../protyle/wysiwyg/commonHotkey";
import {checkFold, renameTag} from "../util/noRelyPCFunction";
@@ -1418,6 +1418,11 @@ export const imgMenu = (protyle: IProtyle, range: Range, assetElement: HTMLEleme
const dataSrc = imgElement.getAttribute("data-src");
if (dataSrc && dataSrc.startsWith("assets/")) {
window.siyuan.menus.menu.append(new MenuItem(exportAsset(dataSrc)).element);
+ /// #if !BROWSER
+ if (["windows", "darwin"].includes(window.siyuan.config.system.os)) {
+ window.siyuan.menus.menu.append(new MenuItem(copyAsset(dataSrc)).element);
+ }
+ /// #endif
}
if (protyle?.app?.plugins) {
emitOpenMenu({
@@ -1691,6 +1696,11 @@ style="margin:4px 0;width: ${isMobile() ? "100%" : "360px"}" class="b3-text-fiel
openMenu(protyle.app, linkAddress, false, true);
if (linkAddress?.startsWith("assets/")) {
window.siyuan.menus.menu.append(new MenuItem(exportAsset(linkAddress)).element);
+ /// #if !BROWSER
+ if (["windows", "darwin"].includes(window.siyuan.config.system.os)) {
+ window.siyuan.menus.menu.append(new MenuItem(copyAsset(linkAddress)).element);
+ }
+ /// #endif
}
}
@@ -2114,6 +2124,11 @@ export const videoMenu = (protyle: IProtyle, nodeElement: Element, type: string)
}
if (src && src.startsWith("assets/")) {
subMenus.push(exportAsset(src));
+ /// #if !BROWSER
+ if (["windows", "darwin"].includes(window.siyuan.config.system.os)) {
+ subMenus.push(copyAsset(src));
+ }
+ /// #endif
}
return subMenus;
};
diff --git a/app/src/menus/util.ts b/app/src/menus/util.ts
index 0a22b0fb2..0cb2d5687 100644
--- a/app/src/menus/util.ts
+++ b/app/src/menus/util.ts
@@ -39,6 +39,28 @@ export const exportAsset = (src: string) => {
};
};
+// 复制资源文件到系统剪贴板,在文件资源管理器中可粘贴为文件(仅 Windows、macOS 桌面端支持)
+export const copyAsset = (src: string) => {
+ return {
+ id: "copy",
+ label: window.siyuan.languages.copyFile,
+ icon: "iconCopy",
+ click: () => {
+ /// #if !BROWSER
+ fetchPost("/api/clipboard/writeFilePath", {path: src}, (response) => {
+ if (response.code === 0) {
+ showMessage(window.siyuan.languages.copied);
+ } else {
+ showMessage(response.msg || "", response.data?.closeTimeout ?? 5000, "error");
+ }
+ });
+ /// #else
+ showMessage("Copy as file is only supported in the Windows and macOS desktop app");
+ /// #endif
+ }
+ };
+};
+
export const openEditorTab = (app: App, ids: string[], notebookId?: string, pathString?: string, onlyGetMenus = false) => {
/// #if !MOBILE
const openSubmenus: IMenu[] = [{
diff --git a/kernel/api/clipboard.go b/kernel/api/clipboard.go
index 6d5b8f8c7..db11661d2 100644
--- a/kernel/api/clipboard.go
+++ b/kernel/api/clipboard.go
@@ -17,17 +17,20 @@
package api
import (
+ "net/http"
"os"
"github.com/88250/clipboard"
"github.com/88250/gulu"
"github.com/gin-gonic/gin"
"github.com/siyuan-note/logging"
+ "github.com/siyuan-note/siyuan/kernel/model"
+ "github.com/siyuan-note/siyuan/kernel/util"
)
func readFilePaths(c *gin.Context) {
ret := gulu.Ret.NewResult()
- defer c.JSON(200, ret)
+ defer c.JSON(http.StatusOK, ret)
var paths []string
if !gulu.OS.IsLinux() { // Linux 端不再支持 `粘贴为纯文本` 时处理文件绝对路径 https://github.com/siyuan-note/siyuan/issues/5825
@@ -52,3 +55,37 @@ func readFilePaths(c *gin.Context) {
}
ret.Data = data
}
+
+func writeFilePath(c *gin.Context) {
+ ret := gulu.Ret.NewResult()
+ defer c.JSON(http.StatusOK, ret)
+
+ arg, ok := util.JsonArg(c, ret)
+ if !ok {
+ return
+ }
+
+ pathArg, ok := arg["path"].(string)
+ if !ok || pathArg == "" {
+ ret.Code = -1
+ ret.Msg = "path is required"
+ return
+ }
+
+ absPath, err := model.GetAssetAbsPath(pathArg)
+ if err != nil {
+ logging.LogErrorf("get asset [%s] abs path failed: %s", pathArg, err)
+ ret.Code = -1
+ ret.Msg = err.Error()
+ ret.Data = map[string]interface{}{"closeTimeout": 5000}
+ return
+ }
+
+ if err = util.WriteFilePaths([]string{absPath}); err != nil {
+ logging.LogErrorf("write file path to clipboard failed: %s", err)
+ ret.Code = -1
+ ret.Msg = err.Error()
+ ret.Data = map[string]interface{}{"closeTimeout": 5000}
+ return
+ }
+}
diff --git a/kernel/api/router.go b/kernel/api/router.go
index e207a5a41..611bb7ed4 100644
--- a/kernel/api/router.go
+++ b/kernel/api/router.go
@@ -297,6 +297,7 @@ func ServeAPI(ginServer *gin.Engine) {
ginServer.Handle("POST", "/api/extension/copy", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, extensionCopy)
ginServer.Handle("POST", "/api/clipboard/readFilePaths", model.CheckAuth, model.CheckAdminRole, readFilePaths)
+ ginServer.Handle("POST", "/api/clipboard/writeFilePath", model.CheckAuth, model.CheckAdminRole, writeFilePath)
ginServer.Handle("POST", "/api/asset/uploadCloud", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, uploadCloud)
ginServer.Handle("POST", "/api/asset/uploadCloudByAssetsPaths", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, uploadCloudByAssetsPaths)
diff --git a/kernel/go.mod b/kernel/go.mod
index b9ac4b4b5..285eba1ae 100644
--- a/kernel/go.mod
+++ b/kernel/go.mod
@@ -38,6 +38,7 @@ require (
github.com/go-ole/go-ole v1.3.0
github.com/gofrs/flock v0.13.0
github.com/golang-jwt/jwt/v5 v5.3.0
+ github.com/gonutz/w32/v2 v2.12.1
github.com/google/uuid v1.6.0
github.com/gorilla/css v1.0.1
github.com/gorilla/websocket v1.5.3
diff --git a/kernel/go.sum b/kernel/go.sum
index 4ea8278b4..9166c7757 100644
--- a/kernel/go.sum
+++ b/kernel/go.sum
@@ -208,6 +208,8 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
+github.com/gonutz/w32/v2 v2.12.1 h1:ZTWg6ZlETDfWK1Qxx+rdWQdQWZwfhiXoyvxzFYdgsUY=
+github.com/gonutz/w32/v2 v2.12.1/go.mod h1:MgtHx0AScDVNKyB+kjyPder4xIi3XAcHS6LDDU2DmdE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
diff --git a/kernel/util/clipboard.go b/kernel/util/clipboard.go
new file mode 100644
index 000000000..6147d2328
--- /dev/null
+++ b/kernel/util/clipboard.go
@@ -0,0 +1,26 @@
+// SiYuan - Refactor your thinking
+// Copyright (c) 2020-present, b3log.org
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//go:build !windows && !darwin
+
+package util
+
+import "errors"
+
+// 当前仅在 Windows、macOS 上实现,其他平台返回错误
+func WriteFilePaths(paths []string) error {
+ return errors.New("writing file paths to clipboard is not supported on this platform")
+}
diff --git a/kernel/util/clipboard_darwin.go b/kernel/util/clipboard_darwin.go
new file mode 100644
index 000000000..38db018a9
--- /dev/null
+++ b/kernel/util/clipboard_darwin.go
@@ -0,0 +1,104 @@
+// SiYuan - Refactor your thinking
+// Copyright (c) 2020-present, b3log.org
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//go:build darwin
+
+// 本文件实现 macOS NSPasteboard 写入文件路径列表:通过 writeObjects: 写入 NSURL 数组
+//(NSPasteboardTypeFileURL / public.file-url),使 Finder 等应用可识别并粘贴为文件。
+//
+// 逻辑依据 Apple 官方「复制到剪贴板」三步:
+// 1) 获取 general pasteboard;2) clearContents 清空;3) writeObjects: 写入符合 NSPasteboardWriting 的对象。
+// NSURL 为系统内置支持类型,写入 file URL 后系统会自动提供 public.file-url、
+// NSFilenamesPboardType、public.utf8-plain-text 等表示,兼容 Finder 与旧版 API。
+//
+// 官方文档与参考:
+// - Pasteboard Programming Guide (macOS)
+// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/PasteboardGuide106/Introduction/Introduction.html
+// - Copying to a Pasteboard(三步流程与 writeObjects:)
+// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/PasteboardGuide106/Articles/pbCopying.html
+// - NSPasteboard
+// https://developer.apple.com/documentation/appkit/nspasteboard
+// - NSPasteboardWriting(NSURL、NSString 等已实现)
+// https://developer.apple.com/documentation/appkit/nspasteboardwriting
+//
+// 下文 /* ... */ 内为 CGO 内联的 Objective-C 代码,由 cgo 提取并编译,并非被注释掉的代码。
+
+package util
+
+/*
+#cgo CFLAGS: -x objective-c
+#cgo LDFLAGS: -framework AppKit -framework Foundation
+#import
+#import
+
+// writeFilePathsToPasteboard 将路径列表写入通用剪贴板,遵循 Copying to a Pasteboard 三步:
+// 1) generalPasteboard;2) clearContents;3) writeObjects: 传入 NSURL 数组。
+// NSURL 符合 NSPasteboardWriting,写入后系统自动提供 public.file-url、NSFilenamesPboardType 等。
+// paths 为 UTF-8 路径字符串数组,count 为数量。
+static int writeFilePathsToPasteboard(const char** paths, int count) {
+ if (count <= 0) return 0;
+ NSMutableArray *arr = [NSMutableArray arrayWithCapacity:(NSUInteger)count];
+ for (int i = 0; i < count; i++) {
+ NSString *path = [NSString stringWithUTF8String:paths[i]];
+ if (!path) continue;
+ NSURL *url = [NSURL fileURLWithPath:path];
+ if (url) [arr addObject:url];
+ }
+ // 若无一有效路径(如全为非法 UTF-8 或无法转为 NSURL),返回 -2 以便 Go 侧报错
+ if ([arr count] == 0) return -2;
+ // 步骤 1:获取通用剪贴板(cut/copy/paste 用)
+ NSPasteboard *pb = [NSPasteboard generalPasteboard];
+ // 步骤 2:清空已有内容,再只写入本次文件路径
+ [pb clearContents];
+ // 步骤 3:writeObjects: 要求对象符合 NSPasteboardWriting,NSURL 已支持
+ BOOL ok = [pb writeObjects:arr];
+ return ok ? 0 : -1;
+}
+*/
+import "C"
+
+import (
+ "errors"
+ "unsafe"
+)
+
+// WriteFilePaths 将文件路径列表写入系统剪贴板(general pasteboard),
+// 使 Finder 等可粘贴为文件。实现见 Pasteboard Guide — Copying to a Pasteboard。
+func WriteFilePaths(paths []string) error {
+ if len(paths) == 0 {
+ return nil
+ }
+ // 分配 C 的 char* 数组,便于传入 Objective-C
+ cPaths := make([]*C.char, len(paths))
+ for i, p := range paths {
+ cPaths[i] = C.CString(p)
+ }
+ defer func() {
+ for _, c := range cPaths {
+ C.free(unsafe.Pointer(c))
+ }
+ }()
+ // 取首元素地址作为 const char** 传入
+ ret := C.writeFilePathsToPasteboard((**C.char)(unsafe.Pointer(&cPaths[0])), C.int(len(paths)))
+ switch ret {
+ case 0:
+ return nil
+ case -2:
+ return errors.New("no valid file paths to write (invalid UTF-8 or path)")
+ default:
+ return errors.New("failed to write file paths to pasteboard")
+ }
+}
diff --git a/kernel/util/clipboard_windows.go b/kernel/util/clipboard_windows.go
new file mode 100644
index 000000000..ce75ea58c
--- /dev/null
+++ b/kernel/util/clipboard_windows.go
@@ -0,0 +1,162 @@
+// SiYuan - Refactor your thinking
+// Copyright (c) 2020-present, b3log.org
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//go:build windows
+
+// 本文件实现 Windows Shell 剪贴板格式 CF_HDROP,用于在剪贴板中传输一组已有文件的路径,使资源管理器等可识别并粘贴为文件。
+//
+// 参考文档:
+// - Shell 剪贴板与 CF_HDROP:https://learn.microsoft.com/en-us/windows/win32/shell/clipboard
+// - DROPFILES 结构:https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-dropfiles
+// - SetClipboardData(hMem 须为 GMEM_MOVEABLE,且 “memory must be unlocked before the Clipboard is closed”):
+// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata
+// - 官方示例 “Copy information to the clipboard”(GlobalUnlock 再 SetClipboardData):
+// https://learn.microsoft.com/en-us/windows/win32/dataxchg/using-the-clipboard
+//
+// CF_HDROP 为预定义格式,无需 RegisterClipboardFormat。数据为全局内存对象(hGlobal),
+// 其内容为 DROPFILES 结构 + 双 null 结尾的路径字符数组。
+
+package util
+
+import (
+ "encoding/binary"
+ "errors"
+ "runtime"
+ "syscall"
+ "time"
+ "unsafe"
+
+ "github.com/gonutz/w32/v2"
+)
+
+const (
+ // cfHDROP 为 CF_HDROP 剪贴板格式(预定义值 15),用于传输一组已有文件的位置。
+ // 见 Standard Clipboard Formats:https://learn.microsoft.com/en-us/windows/win32/dataxchg/standard-clipboard-formats
+ cfHDROP = 15
+ dropfilesSize = 20 // DROPFILES 结构体大小(pFiles 4 + pt 8 + fNC 4 + fWide 4),https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-dropfiles
+)
+
+// WriteFilePaths 将文件路径列表写入系统剪贴板,使资源管理器中可粘贴为文件。
+//
+// 按文档要求,CF_HDROP 数据为 STGMEDIUM 的 hGlobal 指向的全局内存,内存内容为 DROPFILES 结构。
+// 剪贴板 API 要求在同一线程内完成 OpenClipboard、写入、CloseClipboard,故需 LockOSThread。
+// 调用顺序:先准备数据(GlobalAlloc → GlobalLock → 写入 → GlobalUnlock),再 OpenClipboard → EmptyClipboard → SetClipboardData → CloseClipboard。
+// 与官方示例 “Copy information to the clipboard” 不同,此处将内存准备提前到 OpenClipboard 之前,以缩短占用剪贴板的时间。
+func WriteFilePaths(paths []string) error {
+ if len(paths) == 0 {
+ return nil
+ }
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+
+ data, err := buildDropfilesData(paths)
+ if err != nil {
+ return err
+ }
+ if len(data) == 0 {
+ return nil
+ }
+
+ // 全局内存对象;SetClipboardData 文档要求 hMem 须由 GlobalAlloc(GMEM_MOVEABLE) 分配
+ // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata
+ size := uint32(len(data))
+ hMem := w32.GlobalAlloc(w32.GMEM_MOVEABLE, size)
+ if hMem == 0 {
+ return syscall.Errno(w32.GetLastError())
+ }
+
+ ptr := w32.GlobalLock(hMem)
+ if ptr == nil {
+ w32.GlobalFree(hMem)
+ return syscall.Errno(w32.GetLastError())
+ }
+
+ w32.MoveMemory(ptr, unsafe.Pointer(&data[0]), size)
+ // 必须在 SetClipboardData 之前 Unlock,否则系统无法正确管理已接管的句柄。
+ // 文档:"The memory must be unlocked before the Clipboard is closed."
+ // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata
+ w32.GlobalUnlock(hMem)
+
+ if err := waitOpenClipboard(); err != nil {
+ w32.GlobalFree(hMem)
+ return err
+ }
+ defer w32.CloseClipboard()
+
+ if !w32.EmptyClipboard() {
+ w32.GlobalFree(hMem)
+ return syscall.Errno(w32.GetLastError())
+ }
+ if w32.SetClipboardData(cfHDROP, w32.HANDLE(hMem)) == 0 {
+ w32.GlobalFree(hMem)
+ return syscall.Errno(w32.GetLastError())
+ }
+ // 成功时系统接管 hMem,应用不得再写或 free;失败时由上面分支 GlobalFree。
+ // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata
+ return nil
+}
+
+// buildDropfilesData 构建 CF_HDROP 格式的字节切片。
+//
+// 格式遵循 DROPFILES:pFiles 为偏移,指向双 null 结尾的路径字符数组。
+// https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-dropfiles
+// 数组由若干条“完整路径 + 结尾 NULL”组成,最后再跟一个 NULL 结束整表。
+// 例如两文件时为:c:\temp1.txt\0 c:\temp2.txt\0 \0
+// 此处使用 Unicode(fWide=1),故路径为 UTF-16,每条路径含结尾 null,最后再 2 字节 null。
+func buildDropfilesData(paths []string) ([]byte, error) {
+ var totalLen = dropfilesSize
+ for _, p := range paths {
+ u16, err := syscall.UTF16FromString(p)
+ if err != nil {
+ return nil, err
+ }
+ totalLen += len(u16) * 2
+ }
+ totalLen += 2 // 数组末尾的 null(双 null 结尾中的最后一个)
+
+ buf := make([]byte, totalLen)
+ // DROPFILES:pFiles=20(路径数组相对本结构起始的偏移), pt=0,0, fNC=0, fWide=1(Unicode)
+ binary.LittleEndian.PutUint32(buf[0:4], 20)
+ // pt.x, pt.y, fNC, fWide
+ binary.LittleEndian.PutUint32(buf[16:20], 1)
+
+ offset := dropfilesSize
+ for _, p := range paths {
+ u16, err := syscall.UTF16FromString(p)
+ if err != nil {
+ return nil, err
+ }
+ for _, c := range u16 {
+ binary.LittleEndian.PutUint16(buf[offset:offset+2], c)
+ offset += 2
+ }
+ }
+ return buf, nil
+}
+
+// waitOpenClipboard 在限定时间内重试打开剪贴板。
+// 同一时刻仅一进程可持有剪贴板(OpenClipboard 成功)。
+// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-openclipboard
+func waitOpenClipboard() error {
+ deadline := time.Now().Add(time.Second)
+ for time.Now().Before(deadline) {
+ if w32.OpenClipboard(0) {
+ return nil
+ }
+ time.Sleep(time.Millisecond)
+ }
+ return errors.New("open clipboard timeout")
+}