dsh-folder-path 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/README_EN.md +179 -0
- package/client/index.js +306 -0
- package/cordis.patch.yml +21 -0
- package/docs/DETAIL.md +10 -0
- package/docs/DETAIL.zh.md +10 -0
- package/icon.svg +1 -0
- package/lib/client.js +319 -0
- package/lib/index.js +457 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +73 -4
- package/scripts/build-client.mjs +45 -0
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# The dsh-folder-path bundle patch: one row over the profile root.
|
|
2
|
+
#
|
|
3
|
+
# The row name resolves from the profile's own node_modules, where
|
|
4
|
+
# `dsh plugin --profile web add <path>` installed this package. Both halves of
|
|
5
|
+
# the plugin ride this one row: the host half registers the clipboard route, and
|
|
6
|
+
# the browser half (`dsh.client.platform: web` in package.json) is served as a
|
|
7
|
+
# client bundle the module system loads at boot.
|
|
8
|
+
#
|
|
9
|
+
# Every knob is optional. `enabled: false` mounts the host half inert; the
|
|
10
|
+
# browser half then keeps its hands off folder pastes and DSH behaves as before.
|
|
11
|
+
- insert:
|
|
12
|
+
- id: folder-path
|
|
13
|
+
name: dsh-folder-path
|
|
14
|
+
# config:
|
|
15
|
+
# enabled: true
|
|
16
|
+
# timeoutMs: 5000
|
|
17
|
+
# # Executable used for the clipboard probe, per platform:
|
|
18
|
+
# # Windows — PowerShell (default: powershell.exe); it needs Windows Forms.
|
|
19
|
+
# # macOS — osascript (default: /usr/bin/osascript), run with -l JavaScript.
|
|
20
|
+
# powershell: powershell.exe
|
|
21
|
+
# osascript: /usr/bin/osascript
|
package/docs/DETAIL.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Paste folder path
|
|
2
|
+
|
|
3
|
+
Pasting a folder into the composer normally tries to upload it and fails. This plugin reads the folder path from the system clipboard and inserts it instead.
|
|
4
|
+
|
|
5
|
+
## Behavior
|
|
6
|
+
- Windows and macOS only; other platforms report unsupported-platform.
|
|
7
|
+
- The client half intercepts paste; the host half reads the system clipboard (CF_HDROP on Windows, NSPasteboard on macOS).
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
- Falls back to default behavior when the clipboard is unavailable.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# 粘贴文件夹路径
|
|
2
|
+
|
|
3
|
+
浏览器里往输入框粘贴文件夹时,默认行为是尝试上传(文件夹无法上传,只会失败)。这个插件改成读取系统剪贴板里的文件夹路径并插入输入框。
|
|
4
|
+
|
|
5
|
+
## 行为
|
|
6
|
+
- 仅 Windows / macOS 生效;其它平台返回 unsupported-platform。
|
|
7
|
+
- 浏览器半边拦截粘贴事件,宿主半边读系统剪贴板拿路径(Windows 走 CF_HDROP,macOS 走 NSPasteboard)。
|
|
8
|
+
|
|
9
|
+
## 注意
|
|
10
|
+
- 需要宿主能读系统剪贴板;远程或无剪贴板权限时退回默认行为。
|
package/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 11a2 2 0 012-2h6l2.5 3H28a2 2 0 012 2v11a2 2 0 01-2 2H8a2 2 0 01-2-2V11z" fill="#22C55E"/><path d="M18 16v8m0 0l-3-3m3 3l3-3" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-folder-path",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
// ---- client/index.js ----
|
|
8
|
+
/**
|
|
9
|
+
* `dsh-folder-path` browser half.
|
|
10
|
+
*
|
|
11
|
+
* Shipped as the package's `./client` bundle: the DSH client module system
|
|
12
|
+
* loads it as a `window.__ModuleLoader__.load({ id, factory })` script, calls
|
|
13
|
+
* `factory(require)`, and mounts the exported plugin (`apply`) into the browser
|
|
14
|
+
* Cordis tree.
|
|
15
|
+
*
|
|
16
|
+
* What it does — one thing, in the capture phase, before the conversation
|
|
17
|
+
* plugin's Lexical paste command can turn the folder into a doomed upload:
|
|
18
|
+
*
|
|
19
|
+
* * a paste is inspected for entries that are *directories*
|
|
20
|
+
* (`webkitGetAsEntry().isDirectory`; the paste payload is a `File` of size 0
|
|
21
|
+
* with no bytes to upload, which is why 上传失败 appears today);
|
|
22
|
+
* * the pasted folder names go to the host half, which reads the host
|
|
23
|
+
* machine's clipboard (Windows file-drop list, macOS NSPasteboard file
|
|
24
|
+
* URLs) and answers with the real full paths;
|
|
25
|
+
* * those paths are inserted into the composer as plain text by re-dispatching
|
|
26
|
+
* a synthetic text/plain paste, so the insertion goes through DSH's own
|
|
27
|
+
* composer path (command menu, draft state, undo history) instead of
|
|
28
|
+
* poking at the DOM.
|
|
29
|
+
*
|
|
30
|
+
* Ordinary pastes — text, images, real files — are never touched: the handler
|
|
31
|
+
* returns before `preventDefault()` unless a directory is present.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The host route; hand-mirrored from `lib/index.js` (ROUTE_PATH). */
|
|
35
|
+
var ROUTE_PATH = '/dsh-folder-path/api/v1/clipboard-paths'
|
|
36
|
+
|
|
37
|
+
/** Marks the synthetic events this plugin dispatches, so they are not re-read. */
|
|
38
|
+
var SYNTHETIC = '__dshFolderPathSynthetic'
|
|
39
|
+
|
|
40
|
+
/** The composer's editable root, as Lexical renders it. */
|
|
41
|
+
var EDITABLE_SELECTOR = '[contenteditable=""],[contenteditable="true"]'
|
|
42
|
+
|
|
43
|
+
/** How long a failure notice stays on screen. */
|
|
44
|
+
var TOAST_MS = 5000
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Split a paste payload into directories and files.
|
|
48
|
+
*
|
|
49
|
+
* `webkitGetAsEntry()` is the only reliable directory signal. The
|
|
50
|
+
* `size === 0 && type === ''` arm is a fallback for engines without the entry
|
|
51
|
+
* API; it is deliberately not applied when an entry exists, so a genuinely
|
|
52
|
+
* empty file is never mistaken for a folder.
|
|
53
|
+
*
|
|
54
|
+
* @param dataTransfer - the event's clipboard or drag data.
|
|
55
|
+
* @returns directory basenames and the non-directory files.
|
|
56
|
+
*/
|
|
57
|
+
function classifyPayload(dataTransfer) {
|
|
58
|
+
var directories = []
|
|
59
|
+
var files = []
|
|
60
|
+
var items = dataTransfer.items ? Array.prototype.slice.call(dataTransfer.items) : []
|
|
61
|
+
|
|
62
|
+
for (var index = 0; index < items.length; index += 1) {
|
|
63
|
+
var item = items[index]
|
|
64
|
+
if (item.kind !== 'file') continue
|
|
65
|
+
|
|
66
|
+
var entry = null
|
|
67
|
+
try {
|
|
68
|
+
entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null
|
|
69
|
+
} catch (error) {
|
|
70
|
+
entry = null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (entry !== null) {
|
|
74
|
+
// The entry API answers both questions. Reading the file of a directory is
|
|
75
|
+
// deliberately skipped: the browser hands over a zero-byte `File` whose
|
|
76
|
+
// bytes cannot be read, which is the upload failure this plugin exists to
|
|
77
|
+
// avoid, so there is nothing to forward for it.
|
|
78
|
+
if (entry.isDirectory === true) {
|
|
79
|
+
if (entry.name) directories.push(entry.name)
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
var entryFile = null
|
|
83
|
+
try {
|
|
84
|
+
entryFile = item.getAsFile()
|
|
85
|
+
} catch (error) {
|
|
86
|
+
entryFile = null
|
|
87
|
+
}
|
|
88
|
+
if (entryFile !== null) files.push(entryFile)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
var file = null
|
|
93
|
+
try {
|
|
94
|
+
file = item.getAsFile()
|
|
95
|
+
} catch (error) {
|
|
96
|
+
file = null
|
|
97
|
+
}
|
|
98
|
+
if (file === null) continue
|
|
99
|
+
if (file.size === 0 && file.type === '') {
|
|
100
|
+
// No entry API (a non-Chromium engine): a zero-byte, type-less item is
|
|
101
|
+
// the only directory-shaped signal left to go on.
|
|
102
|
+
if (file.name) directories.push(file.name)
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
files.push(file)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { directories: directories, files: files }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Find the composer root a paste belongs to.
|
|
113
|
+
*
|
|
114
|
+
* A paste into anything that is not an editable surface is left alone: the
|
|
115
|
+
* plugin swallows events it handles, and swallowing a paste it cannot insert
|
|
116
|
+
* into would silently eat user data.
|
|
117
|
+
*
|
|
118
|
+
* @param target - the event target.
|
|
119
|
+
* @returns the editable root element, or null.
|
|
120
|
+
*/
|
|
121
|
+
function editableRootOf(target) {
|
|
122
|
+
var candidates = [target, document.activeElement]
|
|
123
|
+
for (var index = 0; index < candidates.length; index += 1) {
|
|
124
|
+
var candidate = candidates[index]
|
|
125
|
+
if (candidate && typeof candidate.closest === 'function') {
|
|
126
|
+
var root = candidate.closest(EDITABLE_SELECTOR)
|
|
127
|
+
if (root !== null) return root
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Ask the host half for the full paths behind the pasted folder names.
|
|
135
|
+
* @param names - directory basenames the browser saw.
|
|
136
|
+
* @returns the resolved paths (empty when the host could not answer).
|
|
137
|
+
*/
|
|
138
|
+
async function requestPaths(names) {
|
|
139
|
+
var response = await fetch(ROUTE_PATH, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: { 'content-type': 'application/json' },
|
|
142
|
+
body: JSON.stringify({ names: names }),
|
|
143
|
+
})
|
|
144
|
+
if (!response.ok) return { paths: [], reason: 'http-' + String(response.status) }
|
|
145
|
+
var payload = await response.json()
|
|
146
|
+
if (payload && payload.ok === true && Array.isArray(payload.paths)) {
|
|
147
|
+
return { paths: payload.paths, reason: null }
|
|
148
|
+
}
|
|
149
|
+
return { paths: [], reason: (payload && payload.reason) || 'unknown' }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Insert plain text through DSH's own paste path.
|
|
154
|
+
*
|
|
155
|
+
* A synthetic `paste` carrying only `text/plain` reaches the same Lexical
|
|
156
|
+
* command a real text paste does, so the draft, command menu and undo history
|
|
157
|
+
* stay consistent. `execCommand` is the fallback for engines that refuse to
|
|
158
|
+
* construct a `ClipboardEvent` with clipboard data.
|
|
159
|
+
*
|
|
160
|
+
* @param root - the composer's editable root.
|
|
161
|
+
* @param text - the text to insert.
|
|
162
|
+
*/
|
|
163
|
+
function insertText(root, text) {
|
|
164
|
+
root.focus({ preventScroll: true })
|
|
165
|
+
try {
|
|
166
|
+
var dataTransfer = new DataTransfer()
|
|
167
|
+
dataTransfer.setData('text/plain', text)
|
|
168
|
+
var event = new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true })
|
|
169
|
+
event[SYNTHETIC] = true
|
|
170
|
+
root.dispatchEvent(event)
|
|
171
|
+
if (event.defaultPrevented) return
|
|
172
|
+
} catch (error) {
|
|
173
|
+
/* fall through to the DOM insertion below */
|
|
174
|
+
}
|
|
175
|
+
document.execCommand('insertText', false, text)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Hand non-directory files back to the composer.
|
|
180
|
+
*
|
|
181
|
+
* Copying a folder and files together is legal; the files still belong to the
|
|
182
|
+
* attachment flow, so they are forwarded as one synthetic file paste after the
|
|
183
|
+
* paths are in.
|
|
184
|
+
*
|
|
185
|
+
* @param root - the composer's editable root.
|
|
186
|
+
* @param files - the files taken out of the original paste.
|
|
187
|
+
*/
|
|
188
|
+
function forwardFiles(root, files) {
|
|
189
|
+
if (files.length === 0) return
|
|
190
|
+
try {
|
|
191
|
+
var dataTransfer = new DataTransfer()
|
|
192
|
+
for (var index = 0; index < files.length; index += 1) dataTransfer.items.add(files[index])
|
|
193
|
+
var event = new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true })
|
|
194
|
+
event[SYNTHETIC] = true
|
|
195
|
+
root.dispatchEvent(event)
|
|
196
|
+
} catch (error) {
|
|
197
|
+
/* the paths are the point; a failed forward must not undo them */
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Failure copy for a host answer.
|
|
203
|
+
* @param reason - the host's reason code.
|
|
204
|
+
* @returns the message to show.
|
|
205
|
+
*/
|
|
206
|
+
function failureText(reason) {
|
|
207
|
+
if (reason === 'unsupported-platform') return '当前宿主系统不支持读取剪贴板里的文件夹路径(仅支持 Windows / macOS)'
|
|
208
|
+
if (reason === 'remote-not-supported') return '当前页面不在运行 dsh web 的这台机器上,读不到这台机器的剪贴板'
|
|
209
|
+
if (reason === 'clipboard-error') return '读取剪贴板失败,没能解析文件夹的完整路径'
|
|
210
|
+
if (reason === 'no-match') return '剪贴板里的内容与粘贴的文件夹不一致,没能解析完整路径'
|
|
211
|
+
return '没能解析文件夹的完整路径(宿主未响应)'
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Show a short-lived notice above the composer.
|
|
216
|
+
* @param message - the text to show.
|
|
217
|
+
*/
|
|
218
|
+
function toast(message) {
|
|
219
|
+
try {
|
|
220
|
+
var element = document.createElement('div')
|
|
221
|
+
element.textContent = message
|
|
222
|
+
element.setAttribute('data-dsh-folder-path-toast', '')
|
|
223
|
+
element.style.cssText =
|
|
224
|
+
'position:fixed;left:50%;bottom:96px;transform:translateX(-50%);z-index:2147483000;' +
|
|
225
|
+
'max-width:70vw;padding:8px 14px;border-radius:10px;background:rgba(28,28,30,0.92);color:#fff;' +
|
|
226
|
+
'font-size:13px;line-height:1.45;box-shadow:0 6px 24px rgba(0,0,0,0.28);pointer-events:none;'
|
|
227
|
+
document.body.appendChild(element)
|
|
228
|
+
setTimeout(function () {
|
|
229
|
+
element.remove()
|
|
230
|
+
}, TOAST_MS)
|
|
231
|
+
} catch (error) {
|
|
232
|
+
/* a missing notice must never break the paste */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Build the browser-half plugin from the bundle's `require`.
|
|
238
|
+
* @param require - the module system's synchronous require.
|
|
239
|
+
* @returns the plugin's `apply`.
|
|
240
|
+
*/
|
|
241
|
+
function createPlugin(require) {
|
|
242
|
+
/**
|
|
243
|
+
* Resolve the pasted folders and put their paths in the composer.
|
|
244
|
+
* @param root - the composer's editable root.
|
|
245
|
+
* @param directories - the pasted directory basenames.
|
|
246
|
+
* @param files - the non-directory files from the same paste.
|
|
247
|
+
*/
|
|
248
|
+
async function resolveAndInsert(root, directories, files) {
|
|
249
|
+
var result
|
|
250
|
+
try {
|
|
251
|
+
result = await requestPaths(directories)
|
|
252
|
+
} catch (error) {
|
|
253
|
+
result = { paths: [], reason: 'host-unreachable' }
|
|
254
|
+
}
|
|
255
|
+
if (result.paths.length === 0) {
|
|
256
|
+
toast(failureText(result.reason))
|
|
257
|
+
forwardFiles(root, files)
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
if (!root.isConnected) {
|
|
261
|
+
toast('输入框已经关闭,路径没有插入:' + result.paths.join('、'))
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
insertText(root, result.paths.join('\n'))
|
|
265
|
+
forwardFiles(root, files)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Capture-phase paste handler: intercepts directory pastes only.
|
|
270
|
+
* @param event - the paste event.
|
|
271
|
+
*/
|
|
272
|
+
function onPaste(event) {
|
|
273
|
+
if (event[SYNTHETIC] === true) return
|
|
274
|
+
if (!event.clipboardData) return
|
|
275
|
+
|
|
276
|
+
var payload = classifyPayload(event.clipboardData)
|
|
277
|
+
if (payload.directories.length === 0) return
|
|
278
|
+
|
|
279
|
+
var root = editableRootOf(event.target)
|
|
280
|
+
if (root === null) return
|
|
281
|
+
|
|
282
|
+
event.preventDefault()
|
|
283
|
+
event.stopPropagation()
|
|
284
|
+
void resolveAndInsert(root, payload.directories, payload.files)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Mount the listener; the returned plugin is applied by the client runner.
|
|
289
|
+
* @param ctx - browser Cordis context.
|
|
290
|
+
*/
|
|
291
|
+
function apply(ctx) {
|
|
292
|
+
document.addEventListener('paste', onPaste, true)
|
|
293
|
+
ctx.effect(
|
|
294
|
+
function () {
|
|
295
|
+
return function () {
|
|
296
|
+
document.removeEventListener('paste', onPaste, true)
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
'dsh-folder-path: paste listener',
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
// Troubleshooting hook: the same resolution the paste path uses, callable
|
|
303
|
+
// from the devtools console (`await __dshFolderPath.probe(['name'])`).
|
|
304
|
+
window.__dshFolderPath = {
|
|
305
|
+
route: ROUTE_PATH,
|
|
306
|
+
probe: requestPaths,
|
|
307
|
+
insert: insertText,
|
|
308
|
+
classify: classifyPayload,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return { apply: apply }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const plugin = createPlugin(require);
|
|
316
|
+
exports.apply = plugin.apply;
|
|
317
|
+
return module.exports;
|
|
318
|
+
}
|
|
319
|
+
});
|