fish-dock-plugin-filemgr 0.0.0-stage → 1.0.4
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 +32 -0
- package/README.md +117 -3
- package/bin/fish-dock-filemgr.mjs +151 -0
- package/dist/filemgr/README.md +62 -0
- package/dist/filemgr/bridge.js +53 -0
- package/dist/filemgr/plugin.json +46 -0
- package/dist/filemgr/plugins/filemgr/FolderPicker.ps1 +260 -0
- package/dist/filemgr/plugins/filemgr/backend.mjs +448 -0
- package/dist/filemgr/plugins/filemgr/clip.ps1 +40 -0
- package/dist/filemgr/plugins/filemgr/dock.css +4 -0
- package/dist/filemgr/plugins/filemgr/filemgr.css +271 -0
- package/dist/filemgr/plugins/filemgr/page.css +218 -0
- package/dist/filemgr/plugins/filemgr/page.html +168 -0
- package/dist/filemgr/plugins/filemgr/page.js +2042 -0
- package/dist/filemgr/plugins/filemgr/pick-files.ps1 +33 -0
- package/dist/filemgr/plugins/filemgr/pick-folder.ps1 +34 -0
- package/dist/filemgr/plugins/filemgr/preview.mjs +456 -0
- package/dist/filemgr/plugins/filemgr/sdk.mjs +123 -0
- package/dist/filemgr/plugins/filemgr/stash.css +67 -0
- package/dist/filemgr/plugins/filemgr/stash.mjs +409 -0
- package/dist/jassdingk.filemgr.fish-plugin +0 -0
- package/package.json +44 -4
|
@@ -0,0 +1,2042 @@
|
|
|
1
|
+
// 文件预览插件页面。所有内容用 DOM API 渲染,不拼 HTML 字符串。
|
|
2
|
+
(() => {
|
|
3
|
+
'use strict'
|
|
4
|
+
|
|
5
|
+
const $ = id => document.getElementById(id)
|
|
6
|
+
const el = (tag, props = {}, children = []) => {
|
|
7
|
+
const node = document.createElement(tag)
|
|
8
|
+
for (const [key, value] of Object.entries(props)) {
|
|
9
|
+
if (value === undefined || value === null || value === false) continue
|
|
10
|
+
if (key === 'class') node.className = value
|
|
11
|
+
else if (key === 'text') node.textContent = value
|
|
12
|
+
else if (key === 'dataset') Object.assign(node.dataset, value)
|
|
13
|
+
else if (key.startsWith('on')) node[key] = value
|
|
14
|
+
else node.setAttribute(key, value === true ? '' : String(value))
|
|
15
|
+
}
|
|
16
|
+
for (const child of [].concat(children)) {
|
|
17
|
+
if (child === null || child === undefined || child === false) continue
|
|
18
|
+
node.append(child instanceof Node ? child : document.createTextNode(String(child)))
|
|
19
|
+
}
|
|
20
|
+
return node
|
|
21
|
+
}
|
|
22
|
+
const clear = node => { while (node.firstChild) node.removeChild(node.firstChild); return node }
|
|
23
|
+
const bytes = n => {
|
|
24
|
+
if (!Number.isFinite(n) || n < 0) return ''
|
|
25
|
+
if (n < 1024) return `${n} B`
|
|
26
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
|
27
|
+
if (n < 1024 * 1024 * 1024) return `${(n / 1048576).toFixed(1)} MB`
|
|
28
|
+
return `${(n / 1073741824).toFixed(2)} GB`
|
|
29
|
+
}
|
|
30
|
+
const when = ms => {
|
|
31
|
+
if (!ms) return ''
|
|
32
|
+
const d = new Date(ms)
|
|
33
|
+
const pad = n => String(n).padStart(2, '0')
|
|
34
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
35
|
+
}
|
|
36
|
+
const extOf = name => {
|
|
37
|
+
const i = String(name).lastIndexOf('.')
|
|
38
|
+
return i > 0 ? String(name).slice(i + 1).toLowerCase() : ''
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const state = {
|
|
42
|
+
roots: [], recent: [], layout: 'single', home: '',
|
|
43
|
+
path: null, parent: null, entries: [],
|
|
44
|
+
file: null, kind: '', busy: false,
|
|
45
|
+
// 左栏宽度(px)。拖动分栏分隔条时改它,松手才上报给后端持久化。
|
|
46
|
+
sideW: 240,
|
|
47
|
+
// 常用文件夹
|
|
48
|
+
favorites: [], pinned: [],
|
|
49
|
+
// —— 文件暂存(filemgr 合并后新增)——
|
|
50
|
+
items: [], groups: [], selected: new Set(), view: 'all', dragCache: new Map(), busy: false,
|
|
51
|
+
// 外层暂存栏宽度(px),由 #fmsplit 拖动控制,持久化到 localStorage。
|
|
52
|
+
stashW: 300,
|
|
53
|
+
// 文件夹列表的排序与「隐藏文件」开关
|
|
54
|
+
sortKey: 'name', sortDir: 1, showHidden: false,
|
|
55
|
+
// 文件夹列表的多选集合(存绝对路径)。点击单行与框选都往这里写,
|
|
56
|
+
// 它是临时状态,不进 prefs.json:换目录就清空。
|
|
57
|
+
browseSel: new Set(),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 默认隐藏的目录名:这些目录只会在文件浏览器里制造噪声。
|
|
61
|
+
const HIDDEN_DIRS = new Set(['node_modules', '.git', '.svn', '.hg', '__pycache__', '.idea', '.vscode', 'target', 'dist', '.next', '.cache'])
|
|
62
|
+
const isHiddenEntry = e => (e.name || '').startsWith('.') || (e.isDir && HIDDEN_DIRS.has(e.name))
|
|
63
|
+
|
|
64
|
+
// 上栏高度边界,与 backend.mjs 的 SIDE_MIN / SIDE_MAX 保持一致。
|
|
65
|
+
const SIDE_MIN = 140
|
|
66
|
+
const SIDE_MAX = 600
|
|
67
|
+
const SIDE_DEFAULT = 420
|
|
68
|
+
|
|
69
|
+
// 把后端/宿主返回的错误规整成一句人话。后端约定返回 {ok:false,error:"文案"},
|
|
70
|
+
// 但宿主协议层错误是 {code,message} 对象(例如后端版本旧、方法不存在),
|
|
71
|
+
// 直接 new Error(对象) 会退化成 "[object Object]",用户完全看不懂。
|
|
72
|
+
const text = error => {
|
|
73
|
+
if (!error) return ''
|
|
74
|
+
if (typeof error === 'string') return error
|
|
75
|
+
if (typeof error.message === 'string' && error.message) return error.message
|
|
76
|
+
if (typeof error.code === 'string') {
|
|
77
|
+
if (error.code === 'UNKNOWN_METHOD') return '插件后台版本过旧,请重启 FISH Dock 让新版本代码生效'
|
|
78
|
+
if (error.code === 'NOT_ACTIVE') return '插件尚未启用,请到设置页开启'
|
|
79
|
+
return error.code
|
|
80
|
+
}
|
|
81
|
+
try { return JSON.stringify(error) } catch { return String(error) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const call = async (method, params = {}) => {
|
|
85
|
+
let row
|
|
86
|
+
try {
|
|
87
|
+
row = await FishDock.call(method, params)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// 宿主 IPC 本身失败(页面票据失效、宿主拒绝等)也统一成人话
|
|
90
|
+
throw new Error(text(error) || '与插件后台通信失败')
|
|
91
|
+
}
|
|
92
|
+
if (!row || row.ok !== true) throw new Error(text(row && row.error) || '读取失败')
|
|
93
|
+
return row
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const say = message => { $('state').textContent = message || '' }
|
|
97
|
+
const bad = message => { $('error').textContent = message || '' }
|
|
98
|
+
|
|
99
|
+
// 头部标题右侧的文件元信息:有选中/预览文件时显示「名 · 修改时间」。
|
|
100
|
+
// 用户要求:文件类型与大小在列表列显示,此处不再重复。
|
|
101
|
+
// 仅浏览目录时清空(.fm-meta:empty 会隐藏)。
|
|
102
|
+
function updateFileMeta() {
|
|
103
|
+
const box = $('file-meta')
|
|
104
|
+
if (!box) return
|
|
105
|
+
const file = state.file
|
|
106
|
+
if (!file || !file.path) { box.textContent = ''; box.removeAttribute('title'); return }
|
|
107
|
+
const parts = [file.name]
|
|
108
|
+
const mtime = when(file.mtime)
|
|
109
|
+
if (mtime) parts.push(mtime)
|
|
110
|
+
const text = parts.filter(Boolean).join(' · ')
|
|
111
|
+
box.textContent = text
|
|
112
|
+
if (text) box.title = file.path
|
|
113
|
+
else box.removeAttribute('title')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const guard = fn => async (...args) => {
|
|
117
|
+
if (state.busy) return
|
|
118
|
+
state.busy = true
|
|
119
|
+
bad('')
|
|
120
|
+
try { await fn(...args) }
|
|
121
|
+
catch (error) { bad(error && error.message ? error.message : String(error)) }
|
|
122
|
+
finally { state.busy = false }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------- 侧栏
|
|
126
|
+
|
|
127
|
+
// 文件的类型文字:目录 → 文件夹,文件 → 大写扩展名(没有扩展名就叫"文件")。
|
|
128
|
+
const typeOf = entry => {
|
|
129
|
+
if (entry.isDir) return '文件夹'
|
|
130
|
+
const m = /\.([^.\\/]+)$/.exec(entry.name || '')
|
|
131
|
+
return m ? m[1].toUpperCase().slice(0, 8) : '文件'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 表头:显示当前排序键与方向(▲/▼)。列宽与 .filelist .row 的 6 列保持一致。
|
|
135
|
+
function drawHead() {
|
|
136
|
+
const head = $('browse-head')
|
|
137
|
+
if (!head) return
|
|
138
|
+
head.hidden = !state.path
|
|
139
|
+
for (const btn of head.querySelectorAll('button[data-sort]')) {
|
|
140
|
+
const on = btn.dataset.sort === state.sortKey
|
|
141
|
+
btn.classList.toggle('on', on)
|
|
142
|
+
btn.dataset.dir = on ? (state.sortDir > 0 ? 'asc' : 'desc') : ''
|
|
143
|
+
}
|
|
144
|
+
const hb = $('browse-hidden')
|
|
145
|
+
if (hb) {
|
|
146
|
+
hb.classList.toggle('on', state.showHidden)
|
|
147
|
+
hb.title = state.showHidden ? '当前显示隐藏项,点击回到只显示常规文件' : '隐藏以 . 开头的文件和 node_modules、dist 等目录'
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 左栏 = 文件夹浏览器(双栏时)。渲染当前目录的条目,点目录进目录、点文件在右栏预览。
|
|
152
|
+
function drawBrowse() {
|
|
153
|
+
const crumbs = clear($('browse-crumbs'))
|
|
154
|
+
if (state.path) {
|
|
155
|
+
const root = state.roots.find(r => state.path.toLowerCase().startsWith(r.path.toLowerCase()))
|
|
156
|
+
const base = root ? root.path : state.path
|
|
157
|
+
crumbs.append(el('button', { type: 'button', onclick: guard(() => openDir(base)) }, root ? (root.label || base) : base))
|
|
158
|
+
let walk = base
|
|
159
|
+
for (const part of state.path.slice(base.length).split(/[\\/]/).filter(Boolean)) {
|
|
160
|
+
walk = walk.replace(/[\\/]+$/, '') + '\\' + part
|
|
161
|
+
crumbs.append(el('span', { text: '/' }))
|
|
162
|
+
const target = walk
|
|
163
|
+
crumbs.append(el('button', { type: 'button', onclick: guard(() => openDir(target)) }, part))
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
crumbs.append(el('span', { text: '选择一个位置' }))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const list = clear($('browse-list'))
|
|
170
|
+
// 先按「隐藏项」过滤,再按当前排序键排。目录永远排在最前面。
|
|
171
|
+
const rows = state.entries.filter(e => state.showHidden || !isHiddenEntry(e)).slice()
|
|
172
|
+
const dir = state.sortDir
|
|
173
|
+
const key = state.sortKey
|
|
174
|
+
rows.sort((a, b) => {
|
|
175
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
|
176
|
+
let r = 0
|
|
177
|
+
if (key === 'name') r = String(a.name).localeCompare(String(b.name), 'zh-Hans-CN')
|
|
178
|
+
else if (key === 'type') r = typeOf(a).localeCompare(typeOf(b), 'zh-Hans-CN')
|
|
179
|
+
else if (key === 'size') r = (a.size || 0) - (b.size || 0)
|
|
180
|
+
else if (key === 'mtime') r = (a.mtime || 0) - (b.mtime || 0)
|
|
181
|
+
if (r === 0) r = String(a.name).localeCompare(String(b.name), 'zh-Hans-CN')
|
|
182
|
+
return r * dir
|
|
183
|
+
})
|
|
184
|
+
drawHead()
|
|
185
|
+
if (!state.path) {
|
|
186
|
+
list.append(el('p', { class: 'empty', text: '还没有打开文件夹' }))
|
|
187
|
+
} else if (!rows.length) {
|
|
188
|
+
list.append(el('p', {
|
|
189
|
+
class: 'empty',
|
|
190
|
+
text: state.entries.length ? '这个目录里的项都被隐藏了,点「隐藏项」查看' : '这个目录是空的',
|
|
191
|
+
}))
|
|
192
|
+
} else {
|
|
193
|
+
if (state.parent) {
|
|
194
|
+
list.append(el('button', {
|
|
195
|
+
type: 'button', class: 'row dir',
|
|
196
|
+
onclick: guard(() => openDir(state.parent)),
|
|
197
|
+
}, [el('span', { class: 'add' }), el('span', { class: 'ni' }), el('span', { class: 'nm', text: '..' }), el('span', { class: 'st', text: '文件夹' }), el('span', { class: 'sz' }), el('span', { class: 'dt' })]))
|
|
198
|
+
}
|
|
199
|
+
for (const entry of rows) {
|
|
200
|
+
// 当前正在预览的文件高亮,让"选中态"一眼可见
|
|
201
|
+
const active = !entry.isDir && state.file && state.file.path === entry.path
|
|
202
|
+
// 已在暂存区/某分组里的文件:打上归属标记,方便识别"加过没加过"。
|
|
203
|
+
// 注意:暂存项里存的是 posix 斜杠(F:/x/y),目录枚举回来是反斜杠(F:\x\y),必须归一化再比。
|
|
204
|
+
const norm = s => String(s || '').replace(/\\/g, '/').toLowerCase()
|
|
205
|
+
const entryKey = entry.isDir ? '' : norm(entry.path)
|
|
206
|
+
const stashed = entryKey ? state.items.find(i => norm(i.path) === entryKey) : null
|
|
207
|
+
{
|
|
208
|
+
const stashBtn = el('span', {
|
|
209
|
+
class: 'add', role: 'button', tabindex: '0',
|
|
210
|
+
title: '加入暂存区', 'aria-label': '加入暂存',
|
|
211
|
+
onclick: ev => { ev.stopPropagation(); guard(() => addToStash(entry.path))() },
|
|
212
|
+
onkeydown: ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); ev.stopPropagation(); guard(() => addToStash(entry.path))() } },
|
|
213
|
+
}, '+')
|
|
214
|
+
list.append(el('button', {
|
|
215
|
+
type: 'button',
|
|
216
|
+
class: 'row' + (entry.isDir ? ' dir' : '') + (active ? ' active' : '') + (stashed ? ' stashed' : '') + (state.browseSel.has(entry.path) ? ' sel' : ''),
|
|
217
|
+
// 框选用:把绝对路径挂在行上,框选/拖动时按 path 匹配,重绘也不会丢选择。
|
|
218
|
+
dataset: { path: entry.path },
|
|
219
|
+
'aria-current': active ? 'true' : 'false',
|
|
220
|
+
'aria-stashed': stashed ? 'true' : 'false',
|
|
221
|
+
'aria-selected': state.browseSel.has(entry.path) ? 'true' : 'false',
|
|
222
|
+
title: stashed ? `${entry.path}\n已在暂存区${stashed.group ? `(分组:${stashed.group})` : '(未分组)'}` : entry.path,
|
|
223
|
+
onclick: guard(() => entry.isDir ? openDir(entry.path) : pickFile(entry.path)),
|
|
224
|
+
}, [
|
|
225
|
+
stashBtn,
|
|
226
|
+
el('span', { class: 'ni' }),
|
|
227
|
+
el('span', { class: 'nm' }, [
|
|
228
|
+
el('span', { class: 'nmt', text: entry.name }),
|
|
229
|
+
stashed ? el('span', { class: 'stash-badge', text: stashed.group ? `已暂存·${stashed.group}` : '已暂存' }) : null,
|
|
230
|
+
].filter(Boolean)),
|
|
231
|
+
el('span', { class: 'st', text: typeOf(entry) }),
|
|
232
|
+
el('span', { class: 'sz', text: entry.isDir ? '' : bytes(entry.size) }),
|
|
233
|
+
el('span', { class: 'dt', text: when(entry.mtime) }),
|
|
234
|
+
].filter(Boolean)))
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
$('browse-up').disabled = !state.parent && !state.file
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 把上栏高度写进 CSS 变量 --side-h。只有双栏时 .workspace 会用它,
|
|
242
|
+
// 单栏时那条规则不生效,所以这里无条件写也无害。
|
|
243
|
+
function applySideHeight() {
|
|
244
|
+
const height = Math.min(SIDE_MAX, Math.max(SIDE_MIN, Math.round(state.sideW) || SIDE_DEFAULT))
|
|
245
|
+
document.documentElement.style.setProperty('--side-h', `${height}px`)
|
|
246
|
+
const split = $('split')
|
|
247
|
+
if (split) split.setAttribute('aria-valuenow', String(height))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function drawSide() {
|
|
251
|
+
document.body.dataset.layout = state.layout
|
|
252
|
+
applySideHeight()
|
|
253
|
+
// 必须告诉宿主当前是单栏还是双栏:宿主按 layout==='double' 才把面板放宽
|
|
254
|
+
// (settings.js 里 WIDE_W),否则一律按 extension-compact 的 620px 走。
|
|
255
|
+
// 高度不用管,宿主注入的 bridge 会 ResizeObserver 盯 main 自动上报。
|
|
256
|
+
if (typeof FishDock !== 'undefined' && typeof FishDock.setLayout === 'function') {
|
|
257
|
+
try { FishDock.setLayout(state.layout) } catch { /* 宿主注入的 bridge 不可用时忽略 */ }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const roots = clear($('roots'))
|
|
261
|
+
if (!state.roots.length) roots.append(el('p', { class: 'note', text: '没有可用的根目录' }))
|
|
262
|
+
for (const root of state.roots) {
|
|
263
|
+
const active = state.path && state.path.toLowerCase().startsWith(root.path.toLowerCase())
|
|
264
|
+
roots.append(el('button', {
|
|
265
|
+
type: 'button',
|
|
266
|
+
title: root.path,
|
|
267
|
+
'aria-current': active ? 'true' : 'false',
|
|
268
|
+
onclick: guard(() => openDir(root.path)),
|
|
269
|
+
}, root.label || root.path))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const recent = clear($('recent'))
|
|
273
|
+
if (!state.recent.length) recent.append(el('p', { class: 'note', text: '暂无记录' }))
|
|
274
|
+
for (const item of state.recent) {
|
|
275
|
+
recent.append(el('button', {
|
|
276
|
+
type: 'button',
|
|
277
|
+
title: item,
|
|
278
|
+
onclick: guard(() => openPath(item)),
|
|
279
|
+
}, item.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || item))
|
|
280
|
+
}
|
|
281
|
+
$('recent-clear').hidden = !state.recent.length
|
|
282
|
+
$('path-line').textContent = state.file ? state.file.path : (state.path || '')
|
|
283
|
+
$('path-line').title = $('path-line').textContent
|
|
284
|
+
drawBrowse()
|
|
285
|
+
updateFileMeta()
|
|
286
|
+
updatePinBtn()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------- 面包屑
|
|
290
|
+
|
|
291
|
+
function drawCrumbs() {
|
|
292
|
+
const box = clear($('crumbs'))
|
|
293
|
+
if (state.file) {
|
|
294
|
+
box.append(el('button', { type: 'button', onclick: guard(() => openDir(dirOf(state.file.path))) }, '← 返回目录'))
|
|
295
|
+
box.append(el('span', { text: state.file.path }))
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
if (!state.path) {
|
|
299
|
+
box.append(el('span', { text: '选择一个位置开始' }))
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
const root = state.roots.find(r => state.path.toLowerCase().startsWith(r.path.toLowerCase()))
|
|
303
|
+
const base = root ? root.path : state.path
|
|
304
|
+
const parts = state.path.slice(base.length).split(/[\\/]/).filter(Boolean)
|
|
305
|
+
box.append(el('button', { type: 'button', onclick: guard(() => openDir(base)) }, root ? (root.label || base) : base))
|
|
306
|
+
let walk = base
|
|
307
|
+
for (const part of parts) {
|
|
308
|
+
walk = walk.replace(/[\\/]+$/, '') + '\\' + part
|
|
309
|
+
box.append(el('span', { text: '/' }))
|
|
310
|
+
const target = walk
|
|
311
|
+
box.append(el('button', { type: 'button', onclick: guard(() => openDir(target)) }, part))
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const dirOf = p => String(p).replace(/[\\/][^\\/]*$/, '') || String(p)
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------- 常用文件夹浮层
|
|
318
|
+
|
|
319
|
+
function drawFavPop() {
|
|
320
|
+
const pinnedBox = clear($('fav-pinned'))
|
|
321
|
+
const recentBox = clear($('fav-recent'))
|
|
322
|
+
// 这两段默认是 hidden(HTML 里写死的),有内容时必须显式打开,
|
|
323
|
+
// 否则 fixed 项渲染进去了却一直看不见。
|
|
324
|
+
pinnedBox.hidden = !state.pinned.length
|
|
325
|
+
recentBox.hidden = false
|
|
326
|
+
// pinned 段
|
|
327
|
+
if (state.pinned.length) {
|
|
328
|
+
const label = el('div', { class: 'fav-label', text: '固定' })
|
|
329
|
+
pinnedBox.append(label)
|
|
330
|
+
for (const f of state.pinned) {
|
|
331
|
+
pinnedBox.append(favItem(f, true))
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// 「最近」段:数据源必须是 state.recent(浏览过的目录,字符串数组),
|
|
335
|
+
// 不是 state.favorites(手动固定的收藏,对象数组)——用错数据源会让
|
|
336
|
+
// 从没固定过任何东西的人永远只看到一句「浏览过的文件夹会出现在这里」。
|
|
337
|
+
const pinnedPaths = new Set(state.pinned.map(p => p.path))
|
|
338
|
+
const recentOnly = state.recent
|
|
339
|
+
.filter(p => !pinnedPaths.has(p))
|
|
340
|
+
.map(p => ({ path: p, name: String(p).replace(/[\\/]+$/, '').split(/[\\/]/).pop() || p }))
|
|
341
|
+
if (recentOnly.length) {
|
|
342
|
+
const label = el('div', { class: 'fav-label', text: '最近' })
|
|
343
|
+
recentBox.append(label)
|
|
344
|
+
for (const f of recentOnly) {
|
|
345
|
+
recentBox.append(favItem(f, false))
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (!state.pinned.length && !recentOnly.length) {
|
|
349
|
+
recentBox.append(el('div', { class: 'fav-label', text: '浏览过的文件夹会出现在这里' }))
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function favItem(f, isPinned) {
|
|
354
|
+
// 注意:el 只接受 (tag, props, children) 三个参数,多传的会被静默丢弃。
|
|
355
|
+
// 这里必须把所有子节点包成一个数组,否则整行只剩一个空按钮(名字/删除键全没)。
|
|
356
|
+
return el('button', { type: 'button', class: 'fav-item', title: f.path,
|
|
357
|
+
onclick: guard(async () => { $('fav-pop').hidden = true; await openDir(f.path) }) },
|
|
358
|
+
[
|
|
359
|
+
isPinned ? el('span', { class: 'fav-pin', text: '📌' }) : null,
|
|
360
|
+
el('span', { class: 'fav-name', text: f.name || f.path }),
|
|
361
|
+
el('span', { class: 'fav-del', text: '✕', title: '从列表中移除',
|
|
362
|
+
onclick(event) { event.stopPropagation(); guard(() => removeFav(f.path))() } }),
|
|
363
|
+
].filter(Boolean))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function removeFav(target) {
|
|
367
|
+
await call('preview.favRemove', { path: target })
|
|
368
|
+
state.favorites = state.favorites.filter(f => f.path !== target)
|
|
369
|
+
state.pinned = state.pinned.filter(f => f.path !== target)
|
|
370
|
+
drawFavPop()
|
|
371
|
+
updatePinBtn()
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function updatePinBtn() {
|
|
375
|
+
const btn = $('fav-pin-btn')
|
|
376
|
+
if (!btn) return
|
|
377
|
+
const curPath = state.path
|
|
378
|
+
if (!curPath) { btn.hidden = true; return }
|
|
379
|
+
const isPinned = state.pinned.some(p => p.path === curPath)
|
|
380
|
+
btn.textContent = isPinned ? '📌' : '📌'
|
|
381
|
+
btn.style.opacity = isPinned ? '1' : '.4'
|
|
382
|
+
btn.title = isPinned ? '取消固定' : '固定当前文件夹'
|
|
383
|
+
btn.hidden = false
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function togglePin() {
|
|
387
|
+
const curPath = state.path
|
|
388
|
+
if (!curPath) return
|
|
389
|
+
// backend 的 preview.favPin 自动确保该目录在 favorites 里
|
|
390
|
+
const name = curPath.split(/[\\/]/).pop() || curPath
|
|
391
|
+
await call('preview.favPin', { path: curPath, name })
|
|
392
|
+
const row = await call('preview.favorites')
|
|
393
|
+
state.favorites = row.favorites
|
|
394
|
+
state.pinned = row.pinned
|
|
395
|
+
drawFavPop()
|
|
396
|
+
updatePinBtn()
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------- 目录视图
|
|
400
|
+
|
|
401
|
+
// 单栏时目录列表画在右栏(无左栏可用);双栏时左栏已经是文件夹浏览器,
|
|
402
|
+
// 右栏专做预览窗口 —— 这时目录列表不再重复渲染,只提示去左栏选文件。
|
|
403
|
+
function drawDir() {
|
|
404
|
+
const view = clear($('view'))
|
|
405
|
+
if (state.layout === 'double') {
|
|
406
|
+
view.append(el('div', { class: 'hint' }, [
|
|
407
|
+
el('h3', { text: state.path || '已打开文件夹' }),
|
|
408
|
+
el('p', { text: `${state.entries.filter(e => e.isDir).length} 个目录,${state.entries.filter(e => !e.isDir).length} 个文件` }),
|
|
409
|
+
el('p', { text: state.dirTrunc ? `目录太大,只列出前 ${state.entries.length} 项(共 ${state.dirTotal} 项)。` : '在左侧列表里点一个文件,这里就会显示它的预览。' }),
|
|
410
|
+
]))
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
if (!state.entries.length) {
|
|
414
|
+
view.append(el('p', { class: 'empty', text: '这个目录是空的' }))
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
const list = el('div', { class: 'filelist' })
|
|
418
|
+
if (state.parent) {
|
|
419
|
+
list.append(el('button', {
|
|
420
|
+
type: 'button', class: 'row dir',
|
|
421
|
+
onclick: guard(() => openDir(state.parent)),
|
|
422
|
+
}, [el('span', { class: 'nm', text: '..' }), el('span', { class: 'sz' }), el('span', { class: 'dt' })]))
|
|
423
|
+
}
|
|
424
|
+
for (const entry of state.entries) {
|
|
425
|
+
const stashBtn = el('span', {
|
|
426
|
+
class: 'stash-add', role: 'button', tabindex: '0',
|
|
427
|
+
title: '加入暂存区', 'aria-label': '加入暂存',
|
|
428
|
+
onclick: ev => { ev.stopPropagation(); guard(() => addToStash(entry.path))() },
|
|
429
|
+
onkeydown: ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); ev.stopPropagation(); guard(() => addToStash(entry.path))() } },
|
|
430
|
+
}, '+暂存')
|
|
431
|
+
const row = el('button', {
|
|
432
|
+
type: 'button',
|
|
433
|
+
class: 'row' + (entry.isDir ? ' dir' : ''),
|
|
434
|
+
title: entry.path,
|
|
435
|
+
onclick: guard(() => entry.isDir ? openDir(entry.path) : openFile(entry.path)),
|
|
436
|
+
}, [
|
|
437
|
+
el('span', { class: 'nm', text: entry.name }),
|
|
438
|
+
el('span', { class: 'sz', text: entry.isDir ? '' : bytes(entry.size) }),
|
|
439
|
+
el('span', { class: 'dt', text: when(entry.mtime) }),
|
|
440
|
+
stashBtn,
|
|
441
|
+
])
|
|
442
|
+
list.append(row)
|
|
443
|
+
}
|
|
444
|
+
view.append(list)
|
|
445
|
+
view.append(el('p', {
|
|
446
|
+
class: 'note',
|
|
447
|
+
text: `${state.entries.filter(e => e.isDir).length} 个目录,${state.entries.filter(e => !e.isDir).length} 个文件`,
|
|
448
|
+
}))
|
|
449
|
+
updateFileMeta()
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---------------------------------------------------------------- 预览分发
|
|
453
|
+
|
|
454
|
+
function head(title, badge, extra = []) {
|
|
455
|
+
return el('div', { class: 'preview-head' }, [
|
|
456
|
+
el('h2', { text: title }),
|
|
457
|
+
badge ? el('span', { class: 'badge', text: badge }) : null,
|
|
458
|
+
...extra,
|
|
459
|
+
])
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function fallback(title, badge, lines, path) {
|
|
463
|
+
const box = el('div', { class: 'hint' }, [
|
|
464
|
+
el('h3', { text: title }),
|
|
465
|
+
...lines.map(text => el('p', { text })),
|
|
466
|
+
el('p', { text: path }),
|
|
467
|
+
])
|
|
468
|
+
return box
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---------------------------------------------------------------- 布局
|
|
472
|
+
|
|
473
|
+
// 切到双栏:左栏文件夹浏览器 + 右栏预览窗口。layout 持久化由后端负责。
|
|
474
|
+
async function gotoDouble() {
|
|
475
|
+
if (state.layout === 'double') return
|
|
476
|
+
state.layout = 'double'
|
|
477
|
+
document.body.dataset.layout = 'double'
|
|
478
|
+
await call('preview.layout', { layout: 'double' }).catch(() => null)
|
|
479
|
+
drawSide()
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 用户主动点「打开文件夹」后:自动进双栏,并把左栏定位到该文件夹、
|
|
483
|
+
// 右栏清空等待选文件。这是「打开文件夹 → 自动双栏」的核心。
|
|
484
|
+
async function openFolderAuto(target) {
|
|
485
|
+
// 先切布局再进目录:这样 openDir 内的 drawDir 一次就画成双栏形态,
|
|
486
|
+
// 不会先渲染一版单栏目录列表再被覆盖。
|
|
487
|
+
if (state.layout !== 'double') {
|
|
488
|
+
state.layout = 'double'
|
|
489
|
+
document.body.dataset.layout = 'double'
|
|
490
|
+
await call('preview.layout', { layout: 'double' }).catch(() => null)
|
|
491
|
+
}
|
|
492
|
+
await openDir(target)
|
|
493
|
+
say('已打开文件夹,在左侧点文件即可预览')
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function openDir(target) {
|
|
497
|
+
const row = await call('preview.tree', { path: target })
|
|
498
|
+
state.path = row.path
|
|
499
|
+
state.parent = row.parent
|
|
500
|
+
state.entries = row.entries
|
|
501
|
+
state.dirTotal = row.total || row.entries.length
|
|
502
|
+
state.dirTrunc = !!row.truncated
|
|
503
|
+
state.file = null
|
|
504
|
+
state.kind = ''
|
|
505
|
+
// 换目录就清空多选:框选的是「这一屏的若干项」,跨目录保留会让人误以为
|
|
506
|
+
// 上一次选中的还在(而且路径已经不在列表里,后续批量操作会很难解释)。
|
|
507
|
+
state.browseSel.clear()
|
|
508
|
+
say(row.truncated ? `${row.entries.length} 项(共 ${row.total} 项,已截断)` : `${row.entries.length} 项`)
|
|
509
|
+
// 打开目录也要写进「最近」——原来只有 openFile() 记,导致只浏览文件夹、
|
|
510
|
+
// 从不打开文件的人点 ★ 永远是空的(这正是「常用文件夹不生效」的主因)。
|
|
511
|
+
const recentRow = await call('preview.remember', { path: row.path }).catch(() => null)
|
|
512
|
+
if (recentRow) state.recent = recentRow.recent || state.recent
|
|
513
|
+
drawSide(); drawCrumbs(); drawDir()
|
|
514
|
+
// 刷新常用文件夹浮层 + pin 按钮状态
|
|
515
|
+
if ($('fav-pop') && !$('fav-pop').hidden) drawFavPop()
|
|
516
|
+
updatePinBtn()
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// 左栏点文件:双栏时只换右侧预览,左栏目录纹丝不动;单栏走整页切换。
|
|
520
|
+
async function pickFile(target) {
|
|
521
|
+
if (state.layout !== 'double') return openFile(target)
|
|
522
|
+
const row = await call('preview.meta', { path: target })
|
|
523
|
+
if (row.isDir) return openDir(row.path)
|
|
524
|
+
return openFile(row.path)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function openPath(target) {
|
|
528
|
+
const row = await call('preview.meta', { path: target })
|
|
529
|
+
if (row.isDir) return openDir(row.path)
|
|
530
|
+
return openFile(row.path)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function openFile(target) {
|
|
534
|
+
const row = await call('preview.meta', { path: target })
|
|
535
|
+
if (row.isDir) return openDir(row.path)
|
|
536
|
+
state.file = row
|
|
537
|
+
// 双栏:左栏是文件夹浏览器,预览文件时不能把它的目录列表清掉。
|
|
538
|
+
// 单栏没有左栏,保持原来「目录视图 → 文件视图」的整页切换。
|
|
539
|
+
if (state.layout === 'double' && state.entries.length &&
|
|
540
|
+
dirOf(state.path || '').toLowerCase() === dirOf(row.path).toLowerCase()) {
|
|
541
|
+
// 同一个目录里换文件:只高亮 + 换右栏,不动左栏
|
|
542
|
+
} else {
|
|
543
|
+
state.path = dirOf(row.path)
|
|
544
|
+
state.entries = []
|
|
545
|
+
// 双栏时左栏必须始终有内容,否则从「最近」跳到别处的文件后左栏会变空。
|
|
546
|
+
if (state.layout === 'double') {
|
|
547
|
+
const treeRow = await call('preview.tree', { path: state.path }).catch(() => null)
|
|
548
|
+
if (treeRow) { state.parent = treeRow.parent; state.entries = treeRow.entries }
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
state.kind = row.kind
|
|
552
|
+
say(`${row.ext || '未知'} · ${bytes(row.size)}`)
|
|
553
|
+
drawSide(); drawCrumbs(); updateFileMeta()
|
|
554
|
+
const recentRow = await call('preview.remember', { path: row.path }).catch(() => null)
|
|
555
|
+
if (recentRow) { state.recent = recentRow.recent; drawSide() }
|
|
556
|
+
const view = clear($('view'))
|
|
557
|
+
view.append(el('p', { role: 'status', text: `正在读取 ${row.name}…` }))
|
|
558
|
+
const badge = `${row.ext || '未知'} · ${bytes(row.size)} · ${when(row.mtime)}`
|
|
559
|
+
try {
|
|
560
|
+
await render(row, view, badge)
|
|
561
|
+
} catch (error) {
|
|
562
|
+
clear(view).append(head(row.name, badge), fallback(
|
|
563
|
+
'无法预览这个文件',
|
|
564
|
+
'',
|
|
565
|
+
[error && error.message ? error.message : String(error), '文件本身可能损坏,或格式暂不支持。'],
|
|
566
|
+
row.path,
|
|
567
|
+
))
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function render(row, view, badge) {
|
|
572
|
+
switch (row.kind) {
|
|
573
|
+
case 'markdown': return renderText(row, view, badge, true)
|
|
574
|
+
case 'code':
|
|
575
|
+
case 'json':
|
|
576
|
+
case 'text':
|
|
577
|
+
case 'html': return renderText(row, view, badge, false)
|
|
578
|
+
case 'image': return renderImage(row, view, badge)
|
|
579
|
+
case 'office': return renderOffice(row, view, badge)
|
|
580
|
+
case 'pdf': return renderPdf(row, view, badge)
|
|
581
|
+
case 'video':
|
|
582
|
+
case 'audio': return renderMedia(row, view, badge)
|
|
583
|
+
default: return renderBinary(row, view, badge)
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ---------------------------------------------------------------- 文本 / 代码 / markdown
|
|
588
|
+
|
|
589
|
+
async function renderText(row, view, badge, markdown) {
|
|
590
|
+
const data = await call('preview.text', { path: row.path })
|
|
591
|
+
clear(view)
|
|
592
|
+
view.append(head(row.name, badge + (data.truncated ? ' · 已截断' : '')))
|
|
593
|
+
if (markdown) {
|
|
594
|
+
const box = el('div', { class: 'md' })
|
|
595
|
+
renderMarkdown(data.content, box, dirOf(row.path))
|
|
596
|
+
view.append(box)
|
|
597
|
+
} else {
|
|
598
|
+
view.append(codeBlock(data.content))
|
|
599
|
+
}
|
|
600
|
+
if (data.truncated) view.append(el('p', { class: 'note', text: `文件较大,只显示前 ${Math.round((data.limit || 819200) / 1024)} KB 内容。` }))
|
|
601
|
+
view.append(footer(row.path))
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function codeBlock(source) {
|
|
605
|
+
const lines = String(source).split('\n')
|
|
606
|
+
const pre = el('pre', { class: 'body' })
|
|
607
|
+
const limit = Math.min(lines.length, 8000)
|
|
608
|
+
const frag = document.createDocumentFragment()
|
|
609
|
+
for (let i = 0; i < limit; i++) {
|
|
610
|
+
frag.append(el('span', { class: 'ln', text: String(i + 1) }))
|
|
611
|
+
frag.append(document.createTextNode(lines[i] + '\n'))
|
|
612
|
+
}
|
|
613
|
+
pre.append(frag)
|
|
614
|
+
const wrap = el('div')
|
|
615
|
+
wrap.append(pre)
|
|
616
|
+
if (lines.length > limit) wrap.append(el('p', { class: 'note', text: `共 ${lines.length} 行,只显示前 ${limit} 行。` }))
|
|
617
|
+
return wrap
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ---------------------------------------------------------------- markdown
|
|
621
|
+
|
|
622
|
+
function renderMarkdown(source, box, baseDir) {
|
|
623
|
+
const lines = String(source).replace(/\r\n?/g, '\n').split('\n')
|
|
624
|
+
let i = 0
|
|
625
|
+
const pendingImages = []
|
|
626
|
+
while (i < lines.length) {
|
|
627
|
+
const line = lines[i]
|
|
628
|
+
const fence = /^\s*(```+|~~~+)\s*([\w+#-]*)\s*$/.exec(line)
|
|
629
|
+
if (fence) {
|
|
630
|
+
const marker = fence[1][0].repeat(3)
|
|
631
|
+
const body = []
|
|
632
|
+
i++
|
|
633
|
+
while (i < lines.length && !new RegExp('^\\s*' + marker).test(lines[i])) { body.push(lines[i]); i++ }
|
|
634
|
+
i++
|
|
635
|
+
const pre = el('pre')
|
|
636
|
+
pre.append(el('code', { text: body.join('\n') }))
|
|
637
|
+
box.append(pre)
|
|
638
|
+
continue
|
|
639
|
+
}
|
|
640
|
+
const atx = /^(#{1,6})\s+(.*)$/.exec(line)
|
|
641
|
+
if (atx) {
|
|
642
|
+
box.append(el('h' + atx[1].length, {}, inline(atx[2].trim())))
|
|
643
|
+
i++; continue
|
|
644
|
+
}
|
|
645
|
+
if (/^\s{0,3}([-*_])\s*\1\s*\1[\s\S]*$/.test(line)) { box.append(el('hr')); i++; continue }
|
|
646
|
+
if (/^\s*>/.test(line)) {
|
|
647
|
+
const body = []
|
|
648
|
+
while (i < lines.length && /^\s*>/.test(lines[i])) { body.push(lines[i].replace(/^\s*>\s?/, '')); i++ }
|
|
649
|
+
const quote = el('blockquote')
|
|
650
|
+
renderMarkdown(body.join('\n'), quote, baseDir)
|
|
651
|
+
box.append(quote); continue
|
|
652
|
+
}
|
|
653
|
+
if (/^\s*\|/.test(line) && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1] || '')) {
|
|
654
|
+
const cell = t => t.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map(s => s.trim())
|
|
655
|
+
const head = cell(lines[i])
|
|
656
|
+
i += 2
|
|
657
|
+
const table = el('table')
|
|
658
|
+
const thead = el('thead')
|
|
659
|
+
const tr = el('tr')
|
|
660
|
+
for (const h of head) tr.append(el('th', {}, inline(h)))
|
|
661
|
+
thead.append(tr); table.append(thead)
|
|
662
|
+
const tbody = el('tbody')
|
|
663
|
+
while (i < lines.length && /^\s*\|/.test(lines[i])) {
|
|
664
|
+
const row = el('tr')
|
|
665
|
+
for (const c of cell(lines[i])) row.append(el('td', {}, inline(c)))
|
|
666
|
+
tbody.append(row); i++
|
|
667
|
+
}
|
|
668
|
+
table.append(tbody); box.append(table)
|
|
669
|
+
continue
|
|
670
|
+
}
|
|
671
|
+
const ul = /^\s*([-*+])\s+(.*)$/.exec(line)
|
|
672
|
+
const ol = /^\s*(\d+)[.)]\s+(.*)$/.exec(line)
|
|
673
|
+
if (ul || ol) {
|
|
674
|
+
const ordered = Boolean(ol)
|
|
675
|
+
const list = el(ordered ? 'ol' : 'ul')
|
|
676
|
+
while (i < lines.length) {
|
|
677
|
+
const m = ordered ? /^\s*(\d+)[.)]\s+(.*)$/.exec(lines[i]) : /^\s*([-*+])\s+(.*)$/.exec(lines[i])
|
|
678
|
+
if (!m) break
|
|
679
|
+
let body = m[2]
|
|
680
|
+
const task = /^\[( |x|X)\]\s+(.*)$/.exec(body)
|
|
681
|
+
let item
|
|
682
|
+
if (task) {
|
|
683
|
+
item = el('li', { class: 'task' }, [task[1].toLowerCase() === 'x' ? '☑ ' : '☐ ', ...inline(task[2])])
|
|
684
|
+
} else {
|
|
685
|
+
item = el('li', {}, inline(body))
|
|
686
|
+
}
|
|
687
|
+
list.append(item)
|
|
688
|
+
i++
|
|
689
|
+
}
|
|
690
|
+
box.append(list)
|
|
691
|
+
continue
|
|
692
|
+
}
|
|
693
|
+
if (!line.trim()) { i++; continue }
|
|
694
|
+
const para = []
|
|
695
|
+
while (i < lines.length && lines[i].trim() && !/^(\s*(```+|~~~+)|#{1,6}\s|\s*>|\s*([-*+]|\d+[.)])\s)/.test(lines[i])) {
|
|
696
|
+
para.push(lines[i]); i++
|
|
697
|
+
}
|
|
698
|
+
if (!para.length) para.push(lines[i++])
|
|
699
|
+
box.append(el('p', {}, inline(para.join('\n'))))
|
|
700
|
+
}
|
|
701
|
+
// 异步补齐相对路径图片
|
|
702
|
+
for (const job of pendingImages) void job()
|
|
703
|
+
return box
|
|
704
|
+
|
|
705
|
+
function inline(text) {
|
|
706
|
+
const nodes = []
|
|
707
|
+
const pattern = /(`[^`]+`)|(!\[[^\]]*\]\([^)\s]+(?:\s+"[^"]*")?\))|(\[[^\]]*\]\([^)\s]+\))|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(~~[^~]+~~)/g
|
|
708
|
+
let last = 0, m
|
|
709
|
+
while ((m = pattern.exec(text))) {
|
|
710
|
+
if (m.index > last) nodes.push(document.createTextNode(text.slice(last, m.index)))
|
|
711
|
+
const token = m[0]
|
|
712
|
+
if (m[1]) nodes.push(el('code', { text: token.slice(1, -1) }))
|
|
713
|
+
else if (m[2]) {
|
|
714
|
+
const alt = /^!\[([^\]]*)\]/.exec(token)[1]
|
|
715
|
+
const src = /\(([^)\s]+)/.exec(token)[1]
|
|
716
|
+
const img = el('img', { alt, src: '', title: src })
|
|
717
|
+
nodes.push(img)
|
|
718
|
+
const resolve = async () => {
|
|
719
|
+
const rel = src.replace(/^\.\//, '')
|
|
720
|
+
if (/^[a-z]+:/i.test(rel) || rel.startsWith('//') || rel.startsWith('#')) {
|
|
721
|
+
if (/^data:/i.test(rel)) img.src = rel
|
|
722
|
+
return
|
|
723
|
+
}
|
|
724
|
+
const target = joinPath(baseDir, decodeURI(rel))
|
|
725
|
+
try {
|
|
726
|
+
const data = await loadDataUrl(target)
|
|
727
|
+
img.src = data
|
|
728
|
+
} catch { /* 图片缺失时保留 alt 文本 */ }
|
|
729
|
+
}
|
|
730
|
+
pendingImages.push(resolve)
|
|
731
|
+
if (alt) nodes.push(document.createTextNode(alt))
|
|
732
|
+
} else if (m[3]) {
|
|
733
|
+
const label = /^\[([^\]]*)\]/.exec(token)[1]
|
|
734
|
+
const href = /\(([^)\s]+)/.exec(token)[1]
|
|
735
|
+
const a = el('a', { href: '#', title: href }, label || href)
|
|
736
|
+
a.onclick = event => { event.preventDefault() }
|
|
737
|
+
nodes.push(a)
|
|
738
|
+
} else if (m[4]) nodes.push(el('strong', { text: token.slice(2, -2) }))
|
|
739
|
+
else if (m[5]) nodes.push(el('em', { text: token.slice(1, -1) }))
|
|
740
|
+
else if (m[6]) nodes.push(el('s', { text: token.slice(2, -2) }))
|
|
741
|
+
last = m.index + token.length
|
|
742
|
+
}
|
|
743
|
+
if (last < text.length) nodes.push(document.createTextNode(text.slice(last)))
|
|
744
|
+
return nodes
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function joinPath(dir, name) {
|
|
749
|
+
const parts = String(name).split(/[\\/]/)
|
|
750
|
+
let base = String(dir).replace(/[\\/]+$/, '')
|
|
751
|
+
for (const part of parts) {
|
|
752
|
+
if (!part || part === '.') continue
|
|
753
|
+
if (part === '..') base = dirOf(base)
|
|
754
|
+
else base = base + '\\' + part
|
|
755
|
+
}
|
|
756
|
+
return base
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ---------------------------------------------------------------- 二进制 → data URL
|
|
760
|
+
|
|
761
|
+
const CHUNK = 360 * 1024
|
|
762
|
+
|
|
763
|
+
async function loadDataUrl(target) {
|
|
764
|
+
const meta = await call('preview.meta', { path: target })
|
|
765
|
+
if (meta.size > 24 * 1024 * 1024) throw new Error('图片超过 24 MB,不做内联预览')
|
|
766
|
+
const chunks = []
|
|
767
|
+
let offset = 0
|
|
768
|
+
for (let guardCount = 0; guardCount < 200; guardCount++) {
|
|
769
|
+
const row = await call('preview.raw', { path: target, offset, limit: CHUNK })
|
|
770
|
+
chunks.push(row.base64)
|
|
771
|
+
offset += row.length
|
|
772
|
+
if (row.done || !row.length) break
|
|
773
|
+
}
|
|
774
|
+
const base64 = chunks.join('')
|
|
775
|
+
return `data:${meta.mime || 'application/octet-stream'};base64,${base64}`
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ---------------------------------------------------------------- 各类预览
|
|
779
|
+
|
|
780
|
+
async function renderImage(row, view, badge) {
|
|
781
|
+
const url = await loadDataUrl(row.path)
|
|
782
|
+
clear(view)
|
|
783
|
+
view.append(head(row.name, badge))
|
|
784
|
+
const figure = el('div', { class: 'figure' })
|
|
785
|
+
const img = el('img', {
|
|
786
|
+
alt: row.name, src: url, title: '点击放大查看大图',
|
|
787
|
+
class: 'zoomable',
|
|
788
|
+
onclick: () => openLightbox(url, row.name),
|
|
789
|
+
})
|
|
790
|
+
figure.append(img)
|
|
791
|
+
figure.append(el('span', { class: 'meta', text: `${row.ext.toUpperCase()} · ${bytes(row.size)} · 点击图片可放大查看` }))
|
|
792
|
+
view.append(figure)
|
|
793
|
+
view.append(footer(row.path))
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ---------------------------------------------------------------- 图片放大查看(灯箱)
|
|
797
|
+
// 点击预览区图片 → 全屏灯箱:滚轮缩放、拖动平移、双击 1:1/适应切换、Esc 或点背景关闭。
|
|
798
|
+
let lb = null // { root, img, scale, fit, tx, ty, dragging, sx, sy, ox, oy }
|
|
799
|
+
function ensureLightbox() {
|
|
800
|
+
if (lb) return lb
|
|
801
|
+
const img = el('img', { class: 'lb-img', alt: '' })
|
|
802
|
+
const tip = el('span', { class: 'lb-tip', text: '滚轮缩放 · 拖动平移 · 双击 1:1/适应 · Esc 关闭' })
|
|
803
|
+
const zoomOut = el('button', { class: 'lb-btn', type: 'button', title: '缩小', text: '−' })
|
|
804
|
+
const zoomIn = el('button', { class: 'lb-btn', type: 'button', title: '放大', text: '+' })
|
|
805
|
+
const fitBtn = el('button', { class: 'lb-btn wide', type: 'button', title: '适应窗口', text: '适应' })
|
|
806
|
+
const realBtn = el('button', { class: 'lb-btn wide', type: 'button', title: '原始大小(1:1)', text: '1:1' })
|
|
807
|
+
const closeBtn = el('button', { class: 'lb-btn wide close', type: 'button', title: '关闭(Esc)', text: '关闭' })
|
|
808
|
+
const bar = el('div', { class: 'lb-bar' }, [zoomOut, zoomIn, fitBtn, realBtn, closeBtn])
|
|
809
|
+
const stage = el('div', { class: 'lb-stage' }, [img])
|
|
810
|
+
const root = el('div', { class: 'lb-overlay', hidden: true }, [stage, bar, tip])
|
|
811
|
+
document.body.append(root)
|
|
812
|
+
const state = { root, img, scale: 1, fit: true, tx: 0, ty: 0, dragging: false, sx: 0, sy: 0, ox: 0, oy: 0 }
|
|
813
|
+
|
|
814
|
+
const apply = () => {
|
|
815
|
+
if (state.fit) { img.style.transform = ''; img.style.maxWidth = '96vw'; img.style.maxHeight = '86vh'; img.style.cursor = 'zoom-in' }
|
|
816
|
+
else {
|
|
817
|
+
img.style.maxWidth = 'none'; img.style.maxHeight = 'none'
|
|
818
|
+
img.style.transform = `translate(${state.tx}px, ${state.ty}px) scale(${state.scale})`
|
|
819
|
+
img.style.cursor = state.dragging ? 'grabbing' : 'grab'
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
const setFit = () => { state.fit = true; state.scale = 1; state.tx = 0; state.ty = 0; apply() }
|
|
823
|
+
const zoomAt = (factor, cx, cy) => {
|
|
824
|
+
if (state.fit) { state.fit = false; state.scale = 1 }
|
|
825
|
+
const next = Math.min(16, Math.max(0.05, state.scale * factor))
|
|
826
|
+
// 以光标为中心缩放:调整平移让命中点保持不动
|
|
827
|
+
const k = next / state.scale
|
|
828
|
+
state.tx = cx - k * (cx - state.tx)
|
|
829
|
+
state.ty = cy - k * (cy - state.ty)
|
|
830
|
+
state.scale = next
|
|
831
|
+
apply()
|
|
832
|
+
}
|
|
833
|
+
const close = () => { root.hidden = true; state.dragging = false }
|
|
834
|
+
state.close = close
|
|
835
|
+
state.setFit = setFit
|
|
836
|
+
state.setReal = () => { state.fit = false; state.scale = 1; state.tx = 0; state.ty = 0; apply() }
|
|
837
|
+
|
|
838
|
+
closeBtn.onclick = close
|
|
839
|
+
fitBtn.onclick = setFit
|
|
840
|
+
realBtn.onclick = state.setReal
|
|
841
|
+
zoomIn.onclick = () => zoomAt(1.25, stage.clientWidth / 2, stage.clientHeight / 2)
|
|
842
|
+
zoomOut.onclick = () => zoomAt(0.8, stage.clientWidth / 2, stage.clientHeight / 2)
|
|
843
|
+
root.addEventListener('click', ev => { if (ev.target === root || ev.target === stage) close() })
|
|
844
|
+
img.addEventListener('dblclick', ev => { ev.preventDefault(); if (state.fit) state.setReal(); else setFit() })
|
|
845
|
+
img.addEventListener('mousedown', ev => {
|
|
846
|
+
if (state.fit) return
|
|
847
|
+
ev.preventDefault(); state.dragging = true; state.sx = ev.clientX; state.sy = ev.clientY; state.ox = state.tx; state.oy = state.ty; apply()
|
|
848
|
+
})
|
|
849
|
+
window.addEventListener('mousemove', ev => {
|
|
850
|
+
if (!state.dragging) return
|
|
851
|
+
state.tx = state.ox + (ev.clientX - state.sx); state.ty = state.oy + (ev.clientY - state.sy); apply()
|
|
852
|
+
})
|
|
853
|
+
window.addEventListener('mouseup', () => { if (state.dragging) { state.dragging = false; apply() } })
|
|
854
|
+
stage.addEventListener('wheel', ev => {
|
|
855
|
+
ev.preventDefault(); const r = stage.getBoundingClientRect()
|
|
856
|
+
zoomAt(ev.deltaY < 0 ? 1.15 : 1 / 1.15, ev.clientX - r.left, ev.clientY - r.top)
|
|
857
|
+
}, { passive: false })
|
|
858
|
+
window.addEventListener('keydown', ev => {
|
|
859
|
+
if (root.hidden) return
|
|
860
|
+
if (ev.key === 'Escape') { close() }
|
|
861
|
+
else if (ev.key === '+' || ev.key === '=') { zoomAt(1.25, stage.clientWidth / 2, stage.clientHeight / 2) }
|
|
862
|
+
else if (ev.key === '-') { zoomAt(0.8, stage.clientWidth / 2, stage.clientHeight / 2) }
|
|
863
|
+
else if (ev.key === '0') { setFit() }
|
|
864
|
+
})
|
|
865
|
+
lb = state
|
|
866
|
+
return state
|
|
867
|
+
}
|
|
868
|
+
function openLightbox(url, name) {
|
|
869
|
+
const state = ensureLightbox()
|
|
870
|
+
state.img.src = url
|
|
871
|
+
state.img.alt = name || ''
|
|
872
|
+
state.root.hidden = false
|
|
873
|
+
state.setFit()
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async function renderOffice(row, view, badge) {
|
|
877
|
+
const data = await call('preview.office', { path: row.path })
|
|
878
|
+
clear(view)
|
|
879
|
+
view.append(head(row.name, badge))
|
|
880
|
+
if (data.kind === 'docx') {
|
|
881
|
+
const doc = el('div', { class: 'doc' })
|
|
882
|
+
let empty = true
|
|
883
|
+
for (const block of data.blocks || []) {
|
|
884
|
+
if (block.type === 'heading') {
|
|
885
|
+
empty = false
|
|
886
|
+
const level = Math.min(6, Math.max(1, Number(block.level) || 3))
|
|
887
|
+
doc.append(el('h4', { class: `dh h${level}`, text: block.text }))
|
|
888
|
+
} else if (block.type === 'para') {
|
|
889
|
+
empty = false
|
|
890
|
+
doc.append(el('p', { class: 'para', text: block.text }))
|
|
891
|
+
} else {
|
|
892
|
+
doc.append(el('div', { class: 'gap' }))
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
view.append(empty ? el('p', { class: 'note', text: '这个 Word 文档没有可提取的文字(可能全是图片)。' }) : doc)
|
|
896
|
+
} else if (data.kind === 'pptx') {
|
|
897
|
+
const slides = el('div', { class: 'slides' })
|
|
898
|
+
for (const slide of data.slides || []) {
|
|
899
|
+
const box = el('div', { class: 'slide' }, [el('h3', { text: `第 ${slide.index} 页` })])
|
|
900
|
+
if (!slide.lines.length) box.append(el('p', { class: 'note', text: '(本页无文字)' }))
|
|
901
|
+
for (const line of slide.lines) box.append(el('p', { text: line }))
|
|
902
|
+
slides.append(box)
|
|
903
|
+
}
|
|
904
|
+
view.append((data.slides || []).length ? slides : el('p', { class: 'note', text: '这个演示文稿没有可提取的文字。' }))
|
|
905
|
+
} else if (data.kind === 'xlsx') {
|
|
906
|
+
for (const sheet of data.sheets || []) {
|
|
907
|
+
const box = el('div', { class: 'sheet' }, [el('h3', { text: sheet.name })])
|
|
908
|
+
const grid = el('div', { class: 'grid' })
|
|
909
|
+
const table = el('table')
|
|
910
|
+
const rows = sheet.rows || []
|
|
911
|
+
if (rows.length) {
|
|
912
|
+
const width = Math.max(...rows.map(r => r.length))
|
|
913
|
+
const thead = el('thead')
|
|
914
|
+
const tr = el('tr')
|
|
915
|
+
for (let c = 0; c < width; c++) tr.append(el('th', { text: columnName(c) }))
|
|
916
|
+
thead.append(tr); table.append(thead)
|
|
917
|
+
const tbody = el('tbody')
|
|
918
|
+
rows.slice(0, 1200).forEach((cells, index) => {
|
|
919
|
+
const row = el('tr')
|
|
920
|
+
row.append(el('th', { text: String(index + 1) }))
|
|
921
|
+
for (let c = 0; c < width; c++) row.append(el('td', { title: cells[c] ?? '', text: cells[c] ?? '' }))
|
|
922
|
+
tbody.append(row)
|
|
923
|
+
})
|
|
924
|
+
table.append(tbody); grid.append(table); box.append(grid)
|
|
925
|
+
if (rows.length > 1200) box.append(el('p', { class: 'note', text: `共 ${rows.length} 行,只显示前 1200 行。` }))
|
|
926
|
+
} else box.append(el('p', { class: 'note', text: '空工作表' }))
|
|
927
|
+
view.append(box)
|
|
928
|
+
}
|
|
929
|
+
if (!(data.sheets || []).length) view.append(el('p', { class: 'note', text: '没有读到工作表。' }))
|
|
930
|
+
} else if (data.kind === 'zip') {
|
|
931
|
+
view.append(el('p', { class: 'note', text: `压缩包共 ${data.total} 项。` }))
|
|
932
|
+
const pre = el('pre', { class: 'body' })
|
|
933
|
+
pre.append(document.createTextNode((data.names || []).join('\n')))
|
|
934
|
+
view.append(pre)
|
|
935
|
+
}
|
|
936
|
+
view.append(footer(row.path))
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function columnName(index) {
|
|
940
|
+
let n = index, name = ''
|
|
941
|
+
do { name = String.fromCharCode(65 + (n % 26)) + name; n = Math.floor(n / 26) - 1 } while (n >= 0)
|
|
942
|
+
return name
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function renderPdf(row, view, badge) {
|
|
946
|
+
clear(view)
|
|
947
|
+
view.append(head(row.name, badge))
|
|
948
|
+
view.append(fallback('PDF 暂不支持内联预览', '', [
|
|
949
|
+
'插件页面运行在沙箱里,没有 PDF 渲染器,也不允许加载外部对象。',
|
|
950
|
+
'请在系统的文件管理器里双击打开这个 PDF。',
|
|
951
|
+
], row.path))
|
|
952
|
+
view.append(footer(row.path))
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function renderMedia(row, view, badge) {
|
|
956
|
+
clear(view)
|
|
957
|
+
view.append(head(row.name, badge))
|
|
958
|
+
view.append(fallback(
|
|
959
|
+
row.kind === 'video' ? '视频暂不支持内联播放' : '音频暂不支持内联播放',
|
|
960
|
+
'',
|
|
961
|
+
['插件页面不允许加载媒体资源。', '请在系统的文件管理器里双击打开。'],
|
|
962
|
+
row.path,
|
|
963
|
+
))
|
|
964
|
+
view.append(footer(row.path))
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function renderBinary(row, view, badge) {
|
|
968
|
+
clear(view)
|
|
969
|
+
view.append(head(row.name, badge))
|
|
970
|
+
view.append(fallback('这是一个二进制文件', '', [
|
|
971
|
+
`无法按文本预览,大小 ${bytes(row.size)}。`,
|
|
972
|
+
'如果需要查看原始字节,可以先用十六进制工具打开。',
|
|
973
|
+
], row.path))
|
|
974
|
+
view.append(footer(row.path))
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function footer(path) {
|
|
978
|
+
const box = el('div', { class: 'foot' })
|
|
979
|
+
const copy = el('button', { type: 'button' }, '复制路径')
|
|
980
|
+
copy.onclick = async () => {
|
|
981
|
+
try {
|
|
982
|
+
await navigator.clipboard.writeText(path)
|
|
983
|
+
copy.textContent = '已复制'
|
|
984
|
+
setTimeout(() => { copy.textContent = '复制路径' }, 1200)
|
|
985
|
+
} catch { copy.textContent = '复制失败' }
|
|
986
|
+
}
|
|
987
|
+
box.append(copy)
|
|
988
|
+
return box
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// ---------------------------------------------------------------- 文件夹选择器
|
|
992
|
+
|
|
993
|
+
// 「打开文件夹」= 直接弹 Windows 系统原生选择文件夹对话框(后端 pick-folder.ps1),
|
|
994
|
+
// 用户选完就跳过去。宿主单次调用有 15s 超时,所以后端先返回 pending,这里轮询。
|
|
995
|
+
let nativeBusy = false
|
|
996
|
+
// ---------------------------------------------------------- 插件内目录选择器
|
|
997
|
+
// 宿主没有 FishDock.hold 时(官方 v1.2.3 及更早)不能弹原生对话框:它会抢走焦点,
|
|
998
|
+
// 宿主 onBlur 判定用户走开就把设置面板收掉,用户选完回来页面已经没了。
|
|
999
|
+
// 这里改用插件自己画的目录树(走 preview.tree),全程不出窗口,焦点不丢。
|
|
1000
|
+
const dirPick = { path: '', sel: '', up: null }
|
|
1001
|
+
|
|
1002
|
+
async function drawDirPick(target) {
|
|
1003
|
+
const box = $('dirpick')
|
|
1004
|
+
if (!box) return
|
|
1005
|
+
box.hidden = false
|
|
1006
|
+
// 依次回落到已知路径。启动还没走完时这些可能全空,先补拉一次 roots,
|
|
1007
|
+
// 否则会拿空串去问后端(只会得到「缺少路径参数」)。
|
|
1008
|
+
let start = target || dirPick.path || state.path || state.home || state.roots[0]?.path || ''
|
|
1009
|
+
if (!start) { await ensureRoots(); start = state.path || state.home || state.roots[0]?.path || '' }
|
|
1010
|
+
dirPick.path = start
|
|
1011
|
+
dirPick.sel = start
|
|
1012
|
+
$('dirpick-path').value = start
|
|
1013
|
+
if (!start) {
|
|
1014
|
+
const note = $('dirpick-note')
|
|
1015
|
+
if (note) note.textContent = '没有可用的根目录,请先在「位置」里添加文件夹'
|
|
1016
|
+
return
|
|
1017
|
+
}
|
|
1018
|
+
await loadDirPick(start)
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// 拉一次根目录/最近记录;启动未完成时由 drawDirPick 兜底调用。
|
|
1022
|
+
async function ensureRoots() {
|
|
1023
|
+
if (state.roots.length) return true
|
|
1024
|
+
try {
|
|
1025
|
+
const row = await call('preview.roots')
|
|
1026
|
+
state.roots = row.roots || []
|
|
1027
|
+
state.recent = row.recent || []
|
|
1028
|
+
state.home = row.home || ''
|
|
1029
|
+
if (row.favorites) state.favorites = row.favorites
|
|
1030
|
+
if (row.pinned) state.pinned = row.pinned
|
|
1031
|
+
} catch {}
|
|
1032
|
+
return state.roots.length > 0
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async function loadDirPick(target) {
|
|
1036
|
+
const list = $('dirpick-list')
|
|
1037
|
+
const note = $('dirpick-note')
|
|
1038
|
+
if (!list) return
|
|
1039
|
+
list.replaceChildren()
|
|
1040
|
+
let row
|
|
1041
|
+
try {
|
|
1042
|
+
row = await call('preview.tree', { path: target })
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
if (note) note.textContent = '读不到这个目录:' + (error.message || error)
|
|
1045
|
+
return
|
|
1046
|
+
}
|
|
1047
|
+
if (!row || row.error) {
|
|
1048
|
+
if (note) note.textContent = (row && row.error) || '读不到这个目录'
|
|
1049
|
+
return
|
|
1050
|
+
}
|
|
1051
|
+
dirPick.path = row.path
|
|
1052
|
+
dirPick.up = row.parent || null
|
|
1053
|
+
$('dirpick-path').value = row.path
|
|
1054
|
+
$('dirpick-title').textContent = '选择文件夹 · ' + row.path
|
|
1055
|
+
const crumbs = $('dirpick-crumbs')
|
|
1056
|
+
if (crumbs) {
|
|
1057
|
+
crumbs.replaceChildren()
|
|
1058
|
+
const parts = row.path.split(/[\\/]/).filter(Boolean)
|
|
1059
|
+
let acc = /^[A-Za-z]:/.test(row.path) ? '' : '/'
|
|
1060
|
+
parts.forEach((part, index) => {
|
|
1061
|
+
acc = index === 0 && /^[A-Za-z]:$/.test(part) ? part + '\\' : (acc.endsWith('\\') || acc.endsWith('/') ? acc + part : acc + '\\' + part)
|
|
1062
|
+
const button = document.createElement('button')
|
|
1063
|
+
button.type = 'button'
|
|
1064
|
+
button.className = 'ghost tiny'
|
|
1065
|
+
button.textContent = part
|
|
1066
|
+
button.onclick = () => guard(loadDirPick(acc))()
|
|
1067
|
+
crumbs.append(button)
|
|
1068
|
+
})
|
|
1069
|
+
}
|
|
1070
|
+
const dirs = (row.entries || []).filter(entry => entry.isDir)
|
|
1071
|
+
if (!dirs.length) {
|
|
1072
|
+
const empty = document.createElement('p')
|
|
1073
|
+
empty.className = 'note'
|
|
1074
|
+
empty.textContent = '这个目录下没有子文件夹'
|
|
1075
|
+
list.append(empty)
|
|
1076
|
+
}
|
|
1077
|
+
for (const entry of dirs) {
|
|
1078
|
+
const line = document.createElement('div')
|
|
1079
|
+
line.className = 'row'
|
|
1080
|
+
line.dataset.path = entry.path
|
|
1081
|
+
line.setAttribute('role', 'option')
|
|
1082
|
+
const name = document.createElement('span')
|
|
1083
|
+
name.className = 'nm'
|
|
1084
|
+
name.textContent = entry.name
|
|
1085
|
+
const into = document.createElement('button')
|
|
1086
|
+
into.type = 'button'
|
|
1087
|
+
into.className = 'ghost tiny'
|
|
1088
|
+
into.textContent = '进入'
|
|
1089
|
+
into.onclick = event => { event.stopPropagation(); guard(loadDirPick(entry.path))() }
|
|
1090
|
+
line.append(name, into)
|
|
1091
|
+
line.onclick = () => {
|
|
1092
|
+
dirPick.sel = entry.path
|
|
1093
|
+
for (const other of list.querySelectorAll('.row')) other.classList.toggle('sel', other === line)
|
|
1094
|
+
$('dirpick-path').value = entry.path
|
|
1095
|
+
}
|
|
1096
|
+
list.append(line)
|
|
1097
|
+
}
|
|
1098
|
+
if (note) note.textContent = row.truncated ? '目录条目过多,只显示前一部分' : ''
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function closeDirPick() {
|
|
1102
|
+
const box = $('dirpick')
|
|
1103
|
+
if (box) box.hidden = true
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function wireDirPick() {
|
|
1107
|
+
const box = $('dirpick')
|
|
1108
|
+
if (!box) return
|
|
1109
|
+
$('dirpick-close').onclick = () => closeDirPick()
|
|
1110
|
+
$('dirpick-up').onclick = guard(async () => { if (dirPick.up) await loadDirPick(dirPick.up) })
|
|
1111
|
+
$('dirpick-ok').onclick = guard(async () => {
|
|
1112
|
+
const typed = ($('dirpick-path').value || '').trim()
|
|
1113
|
+
const target = typed || dirPick.sel || dirPick.path
|
|
1114
|
+
closeDirPick()
|
|
1115
|
+
if (target) await openFolderAuto(target)
|
|
1116
|
+
})
|
|
1117
|
+
$('dirpick-path').onkeydown = event => {
|
|
1118
|
+
if (event.key === 'Enter') { event.preventDefault(); guard(loadDirPick(($('dirpick-path').value || '').trim()))() }
|
|
1119
|
+
}
|
|
1120
|
+
box.onclick = event => { if (event.target === box) closeDirPick() }
|
|
1121
|
+
document.addEventListener('keydown', event => { if (event.key === 'Escape' && !box.hidden) closeDirPick() })
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// 有宿主 hold 就走原生对话框(体验好),没有就走自绘选择器(不失焦)
|
|
1125
|
+
function canHold() { return typeof FishDock !== 'undefined' && typeof FishDock.hold === 'function' }
|
|
1126
|
+
function setHold(on) { try { FishDock.hold?.(!!on) } catch { /* 宿主没注入 hold 时忽略 */ } }
|
|
1127
|
+
async function openNativeFolder() {
|
|
1128
|
+
if (nativeBusy) return
|
|
1129
|
+
if (!canHold()) {
|
|
1130
|
+
await drawDirPick(state.path || state.home || '')
|
|
1131
|
+
return
|
|
1132
|
+
}
|
|
1133
|
+
nativeBusy = true
|
|
1134
|
+
// 只有这一刻才临时锁面板:原生对话框会抢走焦点,宿主 onBlur 会把设置面板收掉,
|
|
1135
|
+
// 用户选完文件夹回来页面就没了。所以先 hold(true),选完在 finally 里放开。
|
|
1136
|
+
// 平时浏览、拖放都不锁,用户仍能照常点外面关闭面板。
|
|
1137
|
+
FishDock.hold?.(true)
|
|
1138
|
+
// 弹系统对话框之前先把当前页面尺寸报给宿主锁定一次,避免对话框关掉后
|
|
1139
|
+
// 宿主按旧高度重排、把刚打开的文件夹列表挤变形。这里只锁尺寸,
|
|
1140
|
+
// 不动「固定文件夹」那一侧的显示优先级。
|
|
1141
|
+
reportHeight()
|
|
1142
|
+
const start = state.file ? dirOf(state.file.path) : (state.path || state.home || '')
|
|
1143
|
+
try {
|
|
1144
|
+
say('请在弹出的系统窗口里选择文件夹…')
|
|
1145
|
+
const first = await call('preview.pickFolder', { initial: start, title: '选择要预览的文件夹' })
|
|
1146
|
+
if (first && first.error) throw new Error(first.error)
|
|
1147
|
+
let done = null
|
|
1148
|
+
for (let i = 0; i < 900; i++) { // 最长等 ~10 分钟,够用户慢慢翻
|
|
1149
|
+
await new Promise(r => setTimeout(r, 700))
|
|
1150
|
+
const row = await call('preview.pickResult')
|
|
1151
|
+
if (row && row.error) throw new Error(row.error)
|
|
1152
|
+
if (!row || row.pending) continue
|
|
1153
|
+
done = row
|
|
1154
|
+
break
|
|
1155
|
+
}
|
|
1156
|
+
if (!done) { say('选择超时'); return }
|
|
1157
|
+
if (done.cancelled) { say('已取消'); return }
|
|
1158
|
+
if (done.roots) state.roots = done.roots
|
|
1159
|
+
const target = done.picked || (done.roots && done.roots[0] && done.roots[0].path)
|
|
1160
|
+
if (target) {
|
|
1161
|
+
$('jump').hidden = true
|
|
1162
|
+
await openFolderAuto(target)
|
|
1163
|
+
}
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
say('') // 别把「请在弹出的…」留在状态行,错误由 #error 显示
|
|
1166
|
+
throw error
|
|
1167
|
+
} finally {
|
|
1168
|
+
nativeBusy = false
|
|
1169
|
+
// 选完(或取消/报错)立刻放开临时锁,恢复「点外面能关面板」的正常行为。
|
|
1170
|
+
setHold(false)
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// ---------------------------------------------------------------- 分栏宽度
|
|
1175
|
+
|
|
1176
|
+
// 拖动分隔条改左栏宽度。拖动过程只改 CSS 变量(不打 IPC,避免每帧一次调用),
|
|
1177
|
+
// 松手才把最终值报给后端存进 prefs.json。
|
|
1178
|
+
function wireSplitter() {
|
|
1179
|
+
const split = $('split')
|
|
1180
|
+
if (!split) return
|
|
1181
|
+
const workspace = document.querySelector('.workspace')
|
|
1182
|
+
let dragging = false
|
|
1183
|
+
let pointerId = null
|
|
1184
|
+
let frame = 0
|
|
1185
|
+
// 用「起点 + 位移」算高度,不去反推分隔条相对容器的偏移:
|
|
1186
|
+
// .workspace 有 gap,分隔条并不紧贴上栏下边缘,按坐标换算会固定差几像素。
|
|
1187
|
+
let startY = 0
|
|
1188
|
+
let startH = SIDE_DEFAULT
|
|
1189
|
+
|
|
1190
|
+
const paint = () => {
|
|
1191
|
+
frame = 0
|
|
1192
|
+
applySideHeight()
|
|
1193
|
+
}
|
|
1194
|
+
const schedule = () => { if (!frame) frame = requestAnimationFrame(paint) }
|
|
1195
|
+
|
|
1196
|
+
const save = () => {
|
|
1197
|
+
const height = Math.round(state.sideW) || SIDE_DEFAULT
|
|
1198
|
+
call('preview.sideWidth', { width: height }).catch(() => null)
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
split.addEventListener('pointerdown', event => {
|
|
1202
|
+
if (event.button !== 0) return
|
|
1203
|
+
dragging = true
|
|
1204
|
+
pointerId = event.pointerId
|
|
1205
|
+
startY = event.clientY
|
|
1206
|
+
startH = state.sideW
|
|
1207
|
+
document.body.dataset.resizing = 'true'
|
|
1208
|
+
split.dataset.dragging = 'true'
|
|
1209
|
+
try { split.setPointerCapture(pointerId) } catch { /* 指针已释放时忽略 */ }
|
|
1210
|
+
event.preventDefault()
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
split.addEventListener('pointermove', event => {
|
|
1214
|
+
if (!dragging) return
|
|
1215
|
+
const next = Math.min(SIDE_MAX, Math.max(SIDE_MIN, Math.round(startH + (event.clientY - startY))))
|
|
1216
|
+
if (next === state.sideW) return
|
|
1217
|
+
state.sideW = next
|
|
1218
|
+
schedule()
|
|
1219
|
+
})
|
|
1220
|
+
|
|
1221
|
+
const stop = () => {
|
|
1222
|
+
if (!dragging) return
|
|
1223
|
+
dragging = false
|
|
1224
|
+
delete document.body.dataset.resizing
|
|
1225
|
+
delete split.dataset.dragging
|
|
1226
|
+
if (pointerId !== null) { try { split.releasePointerCapture(pointerId) } catch { /* 已释放 */ } }
|
|
1227
|
+
pointerId = null
|
|
1228
|
+
if (frame) { cancelAnimationFrame(frame); frame = 0 }
|
|
1229
|
+
applySideHeight()
|
|
1230
|
+
// pointercancel 是拖到一半被打断(比如切窗口),也照样存下当前高度。
|
|
1231
|
+
save()
|
|
1232
|
+
}
|
|
1233
|
+
split.addEventListener('pointerup', stop)
|
|
1234
|
+
split.addEventListener('pointercancel', stop)
|
|
1235
|
+
split.addEventListener('lostpointercapture', stop)
|
|
1236
|
+
|
|
1237
|
+
// 双击恢复默认高度。
|
|
1238
|
+
split.addEventListener('dblclick', () => {
|
|
1239
|
+
state.sideW = SIDE_DEFAULT
|
|
1240
|
+
applySideHeight()
|
|
1241
|
+
save()
|
|
1242
|
+
})
|
|
1243
|
+
|
|
1244
|
+
// 键盘:聚焦分隔条后上下方向键微调(每次 16px),Home 恢复默认。
|
|
1245
|
+
split.addEventListener('keydown', event => {
|
|
1246
|
+
const step = event.shiftKey ? 48 : 16
|
|
1247
|
+
let next = null
|
|
1248
|
+
if (event.key === 'ArrowUp') next = state.sideW - step
|
|
1249
|
+
else if (event.key === 'ArrowDown') next = state.sideW + step
|
|
1250
|
+
else if (event.key === 'Home') next = SIDE_DEFAULT
|
|
1251
|
+
if (next === null) return
|
|
1252
|
+
event.preventDefault()
|
|
1253
|
+
state.sideW = Math.min(SIDE_MAX, Math.max(SIDE_MIN, next))
|
|
1254
|
+
applySideHeight()
|
|
1255
|
+
save()
|
|
1256
|
+
})
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
// ---------------------------------------------------------------- 事件绑定
|
|
1261
|
+
|
|
1262
|
+
$('refresh').onclick = guard(() => state.file ? openFile(state.file.path) : (state.path ? openDir(state.path) : boot()))
|
|
1263
|
+
$('browse-refresh').onclick = guard(() => state.path ? openDir(state.path) : null)
|
|
1264
|
+
$('browse-up').onclick = guard(() => state.parent ? openDir(state.parent) : null)
|
|
1265
|
+
// 隐藏项开关 + 表头排序(点同一列切换升降序)
|
|
1266
|
+
$('browse-hidden').onclick = () => { state.showHidden = !state.showHidden; drawBrowse() }
|
|
1267
|
+
for (const btn of $('browse-head').querySelectorAll('button[data-sort]')) {
|
|
1268
|
+
btn.onclick = () => {
|
|
1269
|
+
const k = btn.dataset.sort
|
|
1270
|
+
if (state.sortKey === k) state.sortDir = -state.sortDir
|
|
1271
|
+
else { state.sortKey = k; state.sortDir = 1 }
|
|
1272
|
+
drawBrowse()
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
// 「上一级」:双栏时左栏才是目录浏览的主体,应该以左栏当前位置为准;
|
|
1276
|
+
// 单栏时页面已经被文件视图占据,只能用文件所在目录回退。
|
|
1277
|
+
$('up').onclick = guard(() => {
|
|
1278
|
+
if (state.layout === 'double') return state.parent ? openDir(state.parent) : null
|
|
1279
|
+
return state.file ? openDir(dirOf(state.file.path)) : (state.parent ? openDir(state.parent) : null)
|
|
1280
|
+
})
|
|
1281
|
+
$('recent-clear').onclick = guard(async () => { const row = await call('preview.forget'); state.recent = row.recent; drawSide() })
|
|
1282
|
+
$('open').onclick = guard(() => openNativeFolder(false))
|
|
1283
|
+
$('pick-native').onclick = guard(async () => { await openNativeFolder(true); drawSide() })
|
|
1284
|
+
$('jump-cancel').onclick = () => { $('jump').hidden = true }
|
|
1285
|
+
$('fav-open').onclick = guard(async () => {
|
|
1286
|
+
const pop = $('fav-pop')
|
|
1287
|
+
if (pop.hidden) {
|
|
1288
|
+
// 每次打开前从后端刷新最新列表
|
|
1289
|
+
const row = await call('preview.favorites')
|
|
1290
|
+
state.favorites = row.favorites
|
|
1291
|
+
state.pinned = row.pinned
|
|
1292
|
+
drawFavPop()
|
|
1293
|
+
pop.hidden = false
|
|
1294
|
+
} else {
|
|
1295
|
+
pop.hidden = true
|
|
1296
|
+
}
|
|
1297
|
+
})
|
|
1298
|
+
$('fav-pin-btn').onclick = guard(togglePin)
|
|
1299
|
+
// 点击浮层外关闭
|
|
1300
|
+
document.addEventListener('click', event => {
|
|
1301
|
+
const pop = $('fav-pop')
|
|
1302
|
+
if (pop && !pop.hidden && !pop.contains(event.target) && event.target !== $('fav-open')) {
|
|
1303
|
+
pop.hidden = true
|
|
1304
|
+
}
|
|
1305
|
+
})
|
|
1306
|
+
$('jump-form').onsubmit = event => {
|
|
1307
|
+
event.preventDefault()
|
|
1308
|
+
const value = $('jump-input').value.trim()
|
|
1309
|
+
if (!value) return
|
|
1310
|
+
$('jump').hidden = true
|
|
1311
|
+
guard(() => openPath(value))()
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// ---------------------------------------------------------------- 启动
|
|
1315
|
+
|
|
1316
|
+
async function bootPreview() {
|
|
1317
|
+
wireDirPick()
|
|
1318
|
+
const row = await call('preview.roots')
|
|
1319
|
+
state.roots = row.roots || []
|
|
1320
|
+
state.recent = row.recent || []
|
|
1321
|
+
state.home = row.home || ''
|
|
1322
|
+
state.favorites = row.favorites || []
|
|
1323
|
+
state.pinned = row.pinned || []
|
|
1324
|
+
// 合并页:预览区固定双栏(左文件夹树 + 右预览),不接受后端 single。
|
|
1325
|
+
state.layout = 'double'
|
|
1326
|
+
document.body.dataset.layout = 'double'
|
|
1327
|
+
if (Number.isFinite(row.sideW)) state.sideW = row.sideW
|
|
1328
|
+
drawSide()
|
|
1329
|
+
updatePinBtn()
|
|
1330
|
+
// 固定落到默认文件夹(F:\AI REP),最近记录只留在侧栏,不抢占启动视图。
|
|
1331
|
+
const home = state.home || state.roots[0]?.path || ''
|
|
1332
|
+
if (home) await openDir(home)
|
|
1333
|
+
else { clear($('view')).append(el('p', { class: 'note', text: '没有可访问的根目录。' })); say('就绪') }
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
const fail = message => { const n = $('error'); if (n) n.textContent = message }
|
|
1338
|
+
|
|
1339
|
+
// ================================================================ 文件暂存(原 stash.js 逻辑)
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
// 简易文件类型图标:目录 / 图片 / 音视频 / 压缩包 / 文本代码 / 兜底
|
|
1343
|
+
const ICONS = [
|
|
1344
|
+
[/^(png|jpg|jpeg|gif|webp|svg|bmp|ico)$/, '🖼️'],
|
|
1345
|
+
[/^(mp3|wav|flac|ogg|m4a)$/, '🎵'],
|
|
1346
|
+
[/^(mp4|mkv|avi|mov|webm)$/, '🎬'],
|
|
1347
|
+
[/^(zip|7z|rar|tar|gz)$/, '🗜️'],
|
|
1348
|
+
[/^(js|mjs|ts|py|json|md|txt|html|css|c|cpp|h|rs|go|java|sh|yml|yaml|toml)$/, '📄'],
|
|
1349
|
+
]
|
|
1350
|
+
const iconOf = item => {
|
|
1351
|
+
if (item.kind === 'dir' || item.kind === 'folder') return '📁'
|
|
1352
|
+
const ext = extOf(item.name)
|
|
1353
|
+
for (const [re, ico] of ICONS) if (re.test(ext)) return ico
|
|
1354
|
+
return '📃'
|
|
1355
|
+
}
|
|
1356
|
+
// DownloadURL 的 mime:按扩展名给个粗略值,宿主资源管理器主要消费文件名与 URL。
|
|
1357
|
+
const MIME = {
|
|
1358
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
|
1359
|
+
pdf: 'application/pdf', zip: 'application/zip', '7z': 'application/x-7z-compressed', rar: 'application/vnd.rar',
|
|
1360
|
+
mp3: 'audio/mpeg', mp4: 'video/mp4', txt: 'text/plain', md: 'text/markdown',
|
|
1361
|
+
json: 'application/json', js: 'text/javascript', mjs: 'text/javascript', css: 'text/css', html: 'text/html',
|
|
1362
|
+
}
|
|
1363
|
+
const mimeOf = item => MIME[extOf(item.name)] || 'application/octet-stream'
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
// ---------------------------------------------------------------- 桥封装(照抄 page/page.js 的 call())
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
// 允许失败的业务调用(拖入合并时逐条容错用)
|
|
1370
|
+
const callSoft = async (method, params = {}) => {
|
|
1371
|
+
try { return await call(method, params) } catch (error) { return { ok: false, error: error.message } }
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const toast = message => {
|
|
1375
|
+
// 轻量提示:复用页脚,2.6s 后自动恢复
|
|
1376
|
+
const foot = $('foot')
|
|
1377
|
+
foot.textContent = message
|
|
1378
|
+
clearTimeout(toast.t)
|
|
1379
|
+
toast.t = setTimeout(() => { foot.textContent = footerText() }, 2600)
|
|
1380
|
+
}
|
|
1381
|
+
const footerText = () => {
|
|
1382
|
+
const n = state.items.length
|
|
1383
|
+
const s = state.selected.size
|
|
1384
|
+
return n ? `共 ${n} 项${s ? `,已选 ${s} 项` : ''}` : ''
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const reportHeight = () => {
|
|
1388
|
+
// 必须按真实布局上报:宿主 onLayout 只有在 layout==='double' 时才会把扩展页
|
|
1389
|
+
// 放宽(EXT_WIDE_W=1280)。原来写死 'single',导致无论页面怎么排都按窄宽度渲染。
|
|
1390
|
+
const layout = state.layout === 'double' ? 'double' : 'single'
|
|
1391
|
+
try { parent.postMessage({ type: 'fish:layout', height: document.documentElement.scrollHeight, layout }, '*') } catch { /* 宿主不可用时忽略 */ }
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// ---------------------------------------------------------------- 数据
|
|
1395
|
+
|
|
1396
|
+
const refresh = async () => {
|
|
1397
|
+
const row = await call('stash.list')
|
|
1398
|
+
state.items = row.items || []
|
|
1399
|
+
state.groups = row.groups || []
|
|
1400
|
+
// 选中项里已不存在的去掉
|
|
1401
|
+
const alive = new Set(state.items.map(i => i.id))
|
|
1402
|
+
for (const id of [...state.selected]) if (!alive.has(id)) state.selected.delete(id)
|
|
1403
|
+
// 当前视图被删组时回到全部
|
|
1404
|
+
if (state.view !== 'all' && state.view !== '' && !state.groups.includes(state.view)) state.view = 'all'
|
|
1405
|
+
draw()
|
|
1406
|
+
}
|
|
1407
|
+
const redraw = () => { draw() }
|
|
1408
|
+
|
|
1409
|
+
const visibleItems = () => state.items.filter(item => {
|
|
1410
|
+
if (state.view === 'all') { /* 全部 */ }
|
|
1411
|
+
else if (state.view === '') { if (item.group) return false }
|
|
1412
|
+
else if (item.group !== state.view) return false
|
|
1413
|
+
return true
|
|
1414
|
+
})
|
|
1415
|
+
|
|
1416
|
+
const selectedItems = () => state.items.filter(i => state.selected.has(i.id))
|
|
1417
|
+
|
|
1418
|
+
// ---------------------------------------------------------------- 渲染
|
|
1419
|
+
|
|
1420
|
+
const draw = () => {
|
|
1421
|
+
drawList()
|
|
1422
|
+
drawMoveSelect()
|
|
1423
|
+
updateToolbar()
|
|
1424
|
+
// 右侧文件列表也要跟着暂存项变化重绘:这样「已暂存」高亮/badge 才能即时反映
|
|
1425
|
+
try { drawBrowse() } catch { /* browse 尚未初始化时忽略 */ }
|
|
1426
|
+
const foot = $('foot')
|
|
1427
|
+
if (!foot.textContent || foot.textContent.startsWith('共 ')) foot.textContent = footerText()
|
|
1428
|
+
reportHeight()
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// 折叠状态记忆:key = 分组名(未分组用 ''),收起时记 false。默认展开。
|
|
1432
|
+
const collapsed = new Map()
|
|
1433
|
+
const isCollapsed = key => collapsed.get(key) === true
|
|
1434
|
+
|
|
1435
|
+
// 把「移动到组」下拉同步成当前分组列表
|
|
1436
|
+
const drawMoveSelect = () => {
|
|
1437
|
+
const sel = $('move-to'); if (!sel) return
|
|
1438
|
+
clear(sel)
|
|
1439
|
+
sel.append(el('option', { value: '', text: '(未分组)' }))
|
|
1440
|
+
for (const g of state.groups) sel.append(el('option', { value: g, text: g }))
|
|
1441
|
+
sel.value = state.view !== 'all' && state.groups.includes(state.view) ? state.view : ''
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// 渲染整张列表:按分组拆成若干个可折叠的 <details>(参考 cost-meter 的「今日」标签)。
|
|
1445
|
+
// 每个分组头就是一条标签,带条目计数;点标签收起/展开该组。
|
|
1446
|
+
// state.view === 'all' 时显示全部分组;否则只显示选中的那一组(选中组强制展开)。
|
|
1447
|
+
const drawList = () => {
|
|
1448
|
+
const list = clear($('list'))
|
|
1449
|
+
const buckets = []
|
|
1450
|
+
const push = (key, label) => {
|
|
1451
|
+
if (state.view !== 'all' && String(state.view) !== key) return
|
|
1452
|
+
const items = state.items.filter(i => (key === '' ? !i.group : i.group === key))
|
|
1453
|
+
buckets.push({ key, label, items })
|
|
1454
|
+
}
|
|
1455
|
+
if (state.view === 'all' || state.view === '') push('', '未分组')
|
|
1456
|
+
for (const g of state.groups) push(g, g)
|
|
1457
|
+
|
|
1458
|
+
const totalShown = buckets.reduce((n, b) => n + b.items.length, 0)
|
|
1459
|
+
$('empty').hidden = totalShown > 0 || buckets.length > 0
|
|
1460
|
+
|
|
1461
|
+
const last = { idx: -1 } // Shift 连选锚点(跨组按全局顺序)
|
|
1462
|
+
const flat = [] // 所有可见项顺序,供 Shift 连选
|
|
1463
|
+
buckets.forEach(b => b.items.forEach(i => flat.push(i)))
|
|
1464
|
+
|
|
1465
|
+
for (const bucket of buckets) {
|
|
1466
|
+
const active = state.view !== 'all' && String(state.view) === bucket.key
|
|
1467
|
+
const det = el('details', { class: 'stash-group' + (active ? ' active' : '') })
|
|
1468
|
+
det.open = !isCollapsed(bucket.key)
|
|
1469
|
+
det.addEventListener('toggle', () => {
|
|
1470
|
+
collapsed.set(bucket.key, !det.open)
|
|
1471
|
+
})
|
|
1472
|
+
|
|
1473
|
+
const summary = el('summary', {
|
|
1474
|
+
class: 'stash-tag' + (active ? ' active' : ''),
|
|
1475
|
+
title: `${bucket.label} · ${bucket.items.length} 项(点箭头/整条折叠;点右侧漏斗图标只在看本组与全部间切换)`,
|
|
1476
|
+
})
|
|
1477
|
+
// 单击整条标签 = 折叠/展开(summary 默认行为,不拦截)。
|
|
1478
|
+
// 切换视图交给独立的漏斗按钮,避免和折叠抢点击。
|
|
1479
|
+
const switchView = ev => {
|
|
1480
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
1481
|
+
if (active) { state.view = 'all' } // 已是当前组 → 回到全部
|
|
1482
|
+
else { state.view = bucket.key === '' ? '' : bucket.key } // 切到本组
|
|
1483
|
+
guard(draw)()
|
|
1484
|
+
drawMoveSelect()
|
|
1485
|
+
}
|
|
1486
|
+
summary.append(el('span', { class: 'stash-tag-dot' }))
|
|
1487
|
+
summary.append(el('span', { class: 'stash-tag-name', text: bucket.label }))
|
|
1488
|
+
summary.append(el('span', { class: 'stash-tag-count', text: `(${bucket.items.length})` }))
|
|
1489
|
+
// 组内「全选 / 取消」:只作用于本组条目;多组叠加互不影响(不 clear 其它组的选中)。
|
|
1490
|
+
// drawList 每次都重建,按钮状态直接从 state.selected 现算,天然同步。
|
|
1491
|
+
if (bucket.items.length) {
|
|
1492
|
+
const ids = bucket.items.map(i => i.id)
|
|
1493
|
+
const allOn = ids.every(id => state.selected.has(id))
|
|
1494
|
+
const toggleBucket = ev => {
|
|
1495
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
1496
|
+
if (allOn) { for (const id of ids) state.selected.delete(id) }
|
|
1497
|
+
else { for (const id of ids) state.selected.add(id) }
|
|
1498
|
+
guard(draw)()
|
|
1499
|
+
}
|
|
1500
|
+
summary.append(el('span', {
|
|
1501
|
+
class: 'stash-tag-select' + (allOn ? ' all' : ''), role: 'button', tabindex: '0',
|
|
1502
|
+
'aria-pressed': String(allOn),
|
|
1503
|
+
title: allOn
|
|
1504
|
+
? `取消全选「${bucket.label}」(${bucket.items.length} 项)`
|
|
1505
|
+
: `全选「${bucket.label}」(${bucket.items.length} 项,不影响其它分组已选)`,
|
|
1506
|
+
onclick: toggleBucket,
|
|
1507
|
+
onkeydown: ev => { if (ev.key === 'Enter' || ev.key === ' ') toggleBucket(ev) },
|
|
1508
|
+
}, allOn ? '取消' : '全选'))
|
|
1509
|
+
}
|
|
1510
|
+
// 筛选按钮:切换「只看本组 / 全部」。用文字而不是 ⌕/◉ 图标——
|
|
1511
|
+
// 图标在这个尺寸下根本认不出是干什么的(可读优先)。
|
|
1512
|
+
summary.append(el('span', {
|
|
1513
|
+
class: 'stash-tag-filter' + (active ? ' on' : ''), role: 'button', tabindex: '0',
|
|
1514
|
+
title: active ? `正在只看「${bucket.label}」,点击回到全部` : `只看「${bucket.label}」`,
|
|
1515
|
+
onclick: switchView,
|
|
1516
|
+
onkeydown: ev => { if (ev.key === 'Enter' || ev.key === ' ') switchView(ev) },
|
|
1517
|
+
}, active ? '看全部' : '只看'))
|
|
1518
|
+
if (bucket.key !== '') {
|
|
1519
|
+
summary.append(el('span', {
|
|
1520
|
+
class: 'stash-tag-del', role: 'button', tabindex: '0', title: `删除分组「${bucket.label}」(条目回到未分组)`,
|
|
1521
|
+
onclick: ev => { ev.preventDefault(); ev.stopPropagation(); guard(() => removeGroup(bucket.key))() },
|
|
1522
|
+
onkeydown: ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); ev.stopPropagation(); guard(() => removeGroup(bucket.key))() } },
|
|
1523
|
+
}, '×'))
|
|
1524
|
+
}
|
|
1525
|
+
det.append(summary)
|
|
1526
|
+
|
|
1527
|
+
if (!bucket.items.length) {
|
|
1528
|
+
det.append(el('p', { class: 'stash-group-empty', text: '这个分组还没有文件。' }))
|
|
1529
|
+
} else {
|
|
1530
|
+
for (const item of bucket.items) det.append(buildRow(item, flat.indexOf(item), flat, last))
|
|
1531
|
+
}
|
|
1532
|
+
list.append(det)
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// 构造单个暂存条目行
|
|
1537
|
+
const buildRow = (item, idx, flat, last) => {
|
|
1538
|
+
const row = el('div', {
|
|
1539
|
+
class: 'row' + (state.selected.has(item.id) ? ' selected' : '') + (item.missing ? ' missing' : ''),
|
|
1540
|
+
role: 'option', 'aria-selected': String(state.selected.has(item.id)), draggable: 'true',
|
|
1541
|
+
dataset: { id: item.id }, title: item.path,
|
|
1542
|
+
})
|
|
1543
|
+
row.append(el('span', { class: 'ico', text: iconOf(item) }))
|
|
1544
|
+
row.append(el('span', { class: 'nm', text: item.name + (item.kind === 'dir' || item.kind === 'folder' ? '/' : '') }))
|
|
1545
|
+
if (item.missing) row.append(el('span', { class: 'miss', text: '文件缺失' }))
|
|
1546
|
+
row.append(el('span', { class: 'note', text: bytes(item.size) }))
|
|
1547
|
+
row.append(el('span', {
|
|
1548
|
+
class: 'stash-preview-btn', role: 'button', tabindex: '0',
|
|
1549
|
+
title: '在右侧预览区打开', 'aria-label': '预览',
|
|
1550
|
+
onclick: ev => { ev.stopPropagation(); guard(() => previewStashItem(item))() },
|
|
1551
|
+
onkeydown: ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); ev.stopPropagation(); guard(() => previewStashItem(item))() } },
|
|
1552
|
+
}, '预览'))
|
|
1553
|
+
|
|
1554
|
+
row.onclick = event => {
|
|
1555
|
+
if (event.shiftKey && last.idx >= 0) {
|
|
1556
|
+
const [a, b] = [Math.min(last.idx, idx), Math.max(last.idx, idx)]
|
|
1557
|
+
state.selected.clear()
|
|
1558
|
+
for (let i = a; i <= b; i++) state.selected.add(flat[i].id)
|
|
1559
|
+
} else if (event.ctrlKey || event.metaKey) {
|
|
1560
|
+
state.selected.has(item.id) ? state.selected.delete(item.id) : state.selected.add(item.id)
|
|
1561
|
+
last.idx = idx
|
|
1562
|
+
} else {
|
|
1563
|
+
state.selected.clear()
|
|
1564
|
+
state.selected.add(item.id)
|
|
1565
|
+
last.idx = idx
|
|
1566
|
+
}
|
|
1567
|
+
draw()
|
|
1568
|
+
}
|
|
1569
|
+
row.ondblclick = guard(() => previewStashItem(item))
|
|
1570
|
+
row.addEventListener('mousedown', () => prefetchDragOut(item))
|
|
1571
|
+
row.addEventListener('pointerdown', () => prefetchDragOut(item))
|
|
1572
|
+
row.addEventListener('dragstart', event => startDragOut(event, item))
|
|
1573
|
+
row.addEventListener('dragend', () => endDragOut(item))
|
|
1574
|
+
return row
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// 删除一个分组(条目回到未分组)
|
|
1578
|
+
const removeGroup = async name => {
|
|
1579
|
+
// 宿主 WebView2 里 window.confirm 会被拦掉(永远返回 false),
|
|
1580
|
+
// 导致点分组标签右侧的 × 完全没反应。这里不再确认:
|
|
1581
|
+
// 删除只是把条目放回「未分组」,不会丢文件,可以重新建组。
|
|
1582
|
+
await call('stash.group.remove', { name })
|
|
1583
|
+
collapsed.delete(name)
|
|
1584
|
+
if (state.view === name) state.view = 'all'
|
|
1585
|
+
say(`已删除分组「${name}」`)
|
|
1586
|
+
await refresh()
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
const updateToolbar = () => {
|
|
1590
|
+
const has = state.selected.size > 0
|
|
1591
|
+
for (const id of ['btn-copy-path', 'btn-copy-file', 'btn-remove']) $(id).disabled = !has
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// ---------------------------------------------------------------- 拖出(DownloadURL 必须同步 setData)
|
|
1595
|
+
|
|
1596
|
+
const prefetchDragOut = item => {
|
|
1597
|
+
if (state.dragCache.has(item.id)) return
|
|
1598
|
+
callSoft('stash.dragOut', { id: item.id }).then(row => {
|
|
1599
|
+
if (row && row.ok && row.url) state.dragCache.set(item.id, { url: row.url, name: row.name || item.name, mime: row.mime || mimeOf(item) })
|
|
1600
|
+
else toast('拖出失败:' + ((row && row.error) || '无法生成下载链接'))
|
|
1601
|
+
})
|
|
1602
|
+
}
|
|
1603
|
+
const startDragOut = (event, item) => {
|
|
1604
|
+
const cached = state.dragCache.get(item.id)
|
|
1605
|
+
event.dataTransfer.effectAllowed = 'copy'
|
|
1606
|
+
if (cached) {
|
|
1607
|
+
// 硬要求:dragstart 里同步 setData,URL 必须来自 mousedown 阶段预取的缓存
|
|
1608
|
+
event.dataTransfer.setData('DownloadURL', `${cached.mime}:${cached.name}:${cached.url}`)
|
|
1609
|
+
event.dataTransfer.setData('text/plain', item.path)
|
|
1610
|
+
event.dataTransfer.setData('text/uri-list', cached.url)
|
|
1611
|
+
} else {
|
|
1612
|
+
// 缓存没来得及:退化为只带纯文本路径
|
|
1613
|
+
event.dataTransfer.setData('text/plain', item.path)
|
|
1614
|
+
toast('正在准备拖出,请重新拖一次')
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
const endDragOut = item => {
|
|
1618
|
+
state.dragCache.delete(item.id)
|
|
1619
|
+
callSoft('stash.closeDragOut', { id: item.id })
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// ---------------------------------------------------------------- 拖入(宿主转发 fish:drag)
|
|
1623
|
+
|
|
1624
|
+
window.addEventListener('message', event => {
|
|
1625
|
+
const data = event.data
|
|
1626
|
+
if (!data || data.type !== 'fish:drag') return
|
|
1627
|
+
const stash = $('stash')
|
|
1628
|
+
if (data.phase === 'enter' || data.phase === 'over') {
|
|
1629
|
+
stash.classList.add('drag-over')
|
|
1630
|
+
$('drop-overlay').hidden = false
|
|
1631
|
+
return
|
|
1632
|
+
}
|
|
1633
|
+
if (data.phase === 'leave') {
|
|
1634
|
+
stash.classList.remove('drag-over')
|
|
1635
|
+
$('drop-overlay').hidden = true
|
|
1636
|
+
return
|
|
1637
|
+
}
|
|
1638
|
+
if (data.phase === 'drop') {
|
|
1639
|
+
stash.classList.remove('drag-over')
|
|
1640
|
+
$('drop-overlay').hidden = true
|
|
1641
|
+
const paths = Array.isArray(data.paths) ? data.paths : []
|
|
1642
|
+
if (!paths.length) return
|
|
1643
|
+
guard(async () => {
|
|
1644
|
+
const group = state.view === 'all' ? '' : state.view
|
|
1645
|
+
// 逐条加入,方便把失败的单独进 toast
|
|
1646
|
+
const results = await Promise.all(paths.map(p => callSoft('stash.add', { paths: [p], group })))
|
|
1647
|
+
let added = 0
|
|
1648
|
+
for (const r of results) {
|
|
1649
|
+
if (r && r.ok) added++
|
|
1650
|
+
else toast('加入暂存失败:' + ((r && r.error) || '未知错误'))
|
|
1651
|
+
}
|
|
1652
|
+
if (added) { say(`已加入 ${added} 项`); await refresh() }
|
|
1653
|
+
else fail('没有文件被加入暂存')
|
|
1654
|
+
})()
|
|
1655
|
+
}
|
|
1656
|
+
})
|
|
1657
|
+
|
|
1658
|
+
// ---------------------------------------------------------------- 工具条动作
|
|
1659
|
+
|
|
1660
|
+
const addPaths = async paths => {
|
|
1661
|
+
const group = state.view === 'all' ? '' : state.view
|
|
1662
|
+
const results = await Promise.all(paths.map(p => callSoft('stash.add', { paths: [p], group })))
|
|
1663
|
+
let added = 0
|
|
1664
|
+
for (const r of results) {
|
|
1665
|
+
if (r && r.ok) added++
|
|
1666
|
+
else toast('加入暂存失败:' + ((r && r.error) || '未知错误'))
|
|
1667
|
+
}
|
|
1668
|
+
if (added) { say(`已加入 ${added} 项`); await refresh() }
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
$('btn-copy-path').onclick = guard(async () => {
|
|
1672
|
+
const items = selectedItems()
|
|
1673
|
+
await call('stash.copy', { mode: 'path', ids: items.map(i => i.id) })
|
|
1674
|
+
toast(`已复制 ${items.length} 条路径`)
|
|
1675
|
+
})
|
|
1676
|
+
$('btn-copy-file').onclick = guard(async () => {
|
|
1677
|
+
const items = selectedItems()
|
|
1678
|
+
await call('stash.copy', { mode: 'file', ids: items.map(i => i.id) })
|
|
1679
|
+
toast(`已复制 ${items.length} 个文件,可到资源管理器粘贴`)
|
|
1680
|
+
})
|
|
1681
|
+
$('btn-remove').onclick = guard(async () => {
|
|
1682
|
+
const items = selectedItems()
|
|
1683
|
+
await call('stash.remove', { ids: items.map(i => i.id) })
|
|
1684
|
+
state.selected.clear()
|
|
1685
|
+
say(`已移除 ${items.length} 项`)
|
|
1686
|
+
await refresh()
|
|
1687
|
+
})
|
|
1688
|
+
$('btn-select-all').onclick = () => {
|
|
1689
|
+
// 只切换「当前可见」条目,不再粗暴 clear —— 以免和分组标签上的组内全选互相冲突。
|
|
1690
|
+
const ids = visibleItems().map(i => i.id)
|
|
1691
|
+
if (!ids.length) return
|
|
1692
|
+
const all = ids.every(id => state.selected.has(id))
|
|
1693
|
+
if (all) for (const id of ids) state.selected.delete(id)
|
|
1694
|
+
else for (const id of ids) state.selected.add(id)
|
|
1695
|
+
draw()
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// 分组操作
|
|
1699
|
+
// 新建组:不用 window.prompt —— WebView2 内嵌页面可能禁用/屏蔽 prompt(返回 null 或抛异常),
|
|
1700
|
+
// 那样 (prompt()||'').trim() 得到空串就会静默什么都不做,用户看到「点了没反应」。
|
|
1701
|
+
// 改成在工具条里就地展开一个输入框,回车/点确定创建,Esc/失焦取消。
|
|
1702
|
+
const openGroupInput = () => {
|
|
1703
|
+
const input = $('group-new-input')
|
|
1704
|
+
const okBtn = $('btn-group-new-ok')
|
|
1705
|
+
if (!input) return
|
|
1706
|
+
input.hidden = false
|
|
1707
|
+
if (okBtn) okBtn.hidden = false
|
|
1708
|
+
input.value = ''
|
|
1709
|
+
input.focus()
|
|
1710
|
+
}
|
|
1711
|
+
const closeGroupInput = () => {
|
|
1712
|
+
const input = $('group-new-input')
|
|
1713
|
+
const okBtn = $('btn-group-new-ok')
|
|
1714
|
+
if (input) { input.hidden = true; input.value = '' }
|
|
1715
|
+
if (okBtn) okBtn.hidden = true
|
|
1716
|
+
}
|
|
1717
|
+
const commitGroupInput = guard(async () => {
|
|
1718
|
+
const input = $('group-new-input')
|
|
1719
|
+
if (!input) return
|
|
1720
|
+
const name = (input.value || '').trim()
|
|
1721
|
+
if (!name) { closeGroupInput(); return }
|
|
1722
|
+
if (state.groups.includes(name)) { fail('分组已存在'); closeGroupInput(); return }
|
|
1723
|
+
closeGroupInput()
|
|
1724
|
+
await call('stash.group.add', { name })
|
|
1725
|
+
state.view = 'all'
|
|
1726
|
+
say(`已创建分组「${name}」`)
|
|
1727
|
+
await refresh()
|
|
1728
|
+
})
|
|
1729
|
+
$('btn-group-new').onclick = () => openGroupInput()
|
|
1730
|
+
if ($('btn-group-new-ok')) $('btn-group-new-ok').onclick = commitGroupInput
|
|
1731
|
+
if ($('group-new-input')) {
|
|
1732
|
+
const input = $('group-new-input')
|
|
1733
|
+
input.addEventListener('keydown', event => {
|
|
1734
|
+
if (event.key === 'Enter') { event.preventDefault(); commitGroupInput() }
|
|
1735
|
+
else if (event.key === 'Escape') { event.preventDefault(); closeGroupInput() }
|
|
1736
|
+
})
|
|
1737
|
+
// 失焦时:如果填了名字就提交,没填就收起(避免点「确定」时先触发 blur 导致重复)
|
|
1738
|
+
input.addEventListener('blur', () => {
|
|
1739
|
+
if (!(input.value || '').trim()) closeGroupInput()
|
|
1740
|
+
})
|
|
1741
|
+
}
|
|
1742
|
+
$('move-to').onchange = guard(async event => {
|
|
1743
|
+
const target = event.target.value
|
|
1744
|
+
const items = selectedItems()
|
|
1745
|
+
if (!items.length) { toast('先选中条目再移动'); return }
|
|
1746
|
+
await call('stash.move', { ids: items.map(i => i.id), group: target })
|
|
1747
|
+
say(`已移动 ${items.length} 项到「${target || '未分组'}」`)
|
|
1748
|
+
await refresh()
|
|
1749
|
+
})
|
|
1750
|
+
|
|
1751
|
+
// ---------------------------------------------------------------- 启动
|
|
1752
|
+
|
|
1753
|
+
// 互关联①:文件列表项 → 加入暂存。当前分组作为落点,加完刷新暂存列表。
|
|
1754
|
+
async function addToStash(target) {
|
|
1755
|
+
const group = state.view === 'all' ? '' : state.view
|
|
1756
|
+
const row = await callSoft('stash.add', { paths: [target], group })
|
|
1757
|
+
if (row && row.ok) {
|
|
1758
|
+
const added = Array.isArray(row.items) ? row.items.length : 1
|
|
1759
|
+
if (added) { say('已加入暂存'); await refresh() } else { toast('已在暂存中,或文件无法加入') }
|
|
1760
|
+
} else {
|
|
1761
|
+
fail('加入暂存失败:' + ((row && row.error) || '未知错误'))
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// 批量加入(框选后拖进暂存区走这一条)。
|
|
1766
|
+
// 后端 stash.add 本来就吃 paths 数组,所以只是把多行一次性递过去;
|
|
1767
|
+
// 逐个报告"加了几项/几项重复",比只弹一个含糊的 toast 更好懂。
|
|
1768
|
+
async function addManyToStash(paths) {
|
|
1769
|
+
const list = (paths || []).filter(Boolean)
|
|
1770
|
+
if (!list.length) return
|
|
1771
|
+
const group = state.view === 'all' ? '' : state.view
|
|
1772
|
+
const row = await callSoft('stash.add', { paths: list, group })
|
|
1773
|
+
if (row && row.ok) {
|
|
1774
|
+
const added = Array.isArray(row.items) ? row.items.length : 0
|
|
1775
|
+
const dup = list.length - added
|
|
1776
|
+
const head = added ? `已加入暂存 ${added} 项` : '这些都已经在暂存里了'
|
|
1777
|
+
say(dup > 0 && added > 0 ? `${head},${dup} 项重复已跳过` : head)
|
|
1778
|
+
if (added) await refresh()
|
|
1779
|
+
} else {
|
|
1780
|
+
fail('加入暂存失败:' + ((row && row.error) || '未知错误'))
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// ================================================================ 框选 + 拖到暂存区
|
|
1785
|
+
|
|
1786
|
+
// 指针状态机:idle → pending(按下未移动)→ box(空白处拖出选框)/ drag(从行上拖走)。
|
|
1787
|
+
// pending 时原地松手,就交还给原有的 click(打开文件/进入目录),不抢原有交互。
|
|
1788
|
+
function wireBrowseSelect() {
|
|
1789
|
+
const list = $('browse-list')
|
|
1790
|
+
const rubber = $('rubber')
|
|
1791
|
+
const stashCol = $('stash-col')
|
|
1792
|
+
if (!list || !rubber) return
|
|
1793
|
+
|
|
1794
|
+
const THRESHOLD = 5 // 位移小于它算点击,不算拖动
|
|
1795
|
+
let mode = 'idle'
|
|
1796
|
+
let startX = 0, startY = 0
|
|
1797
|
+
let startRow = null // 起点落在哪一行(null = 点在空白处)
|
|
1798
|
+
let baseSel = [] // 拖动/框选开始时已有的选择,按住 Shift 时参与累加
|
|
1799
|
+
let dragPaths = []
|
|
1800
|
+
let moved = false
|
|
1801
|
+
let suppressClick = false
|
|
1802
|
+
let ghost = null
|
|
1803
|
+
|
|
1804
|
+
const rows = () => Array.from(list.querySelectorAll('.row[data-path]'))
|
|
1805
|
+
|
|
1806
|
+
// 只改 class,不整片重绘:框选要跟手,重建 DOM 会闪。
|
|
1807
|
+
const paint = () => {
|
|
1808
|
+
for (const node of rows()) {
|
|
1809
|
+
const on = state.browseSel.has(node.dataset.path)
|
|
1810
|
+
node.classList.toggle('sel', on)
|
|
1811
|
+
node.setAttribute('aria-selected', on ? 'true' : 'false')
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
const boxSelect = (rect, additive) => {
|
|
1816
|
+
const picked = new Set(additive ? baseSel : [])
|
|
1817
|
+
for (const node of rows()) {
|
|
1818
|
+
const b = node.getBoundingClientRect()
|
|
1819
|
+
// 相交即选中,符合"拖框扫过去就中"的直觉
|
|
1820
|
+
if (!(b.right < rect.left || b.left > rect.right || b.bottom < rect.top || b.top > rect.bottom)) {
|
|
1821
|
+
picked.add(node.dataset.path)
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
state.browseSel = picked
|
|
1825
|
+
paint()
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
const reset = () => {
|
|
1829
|
+
mode = 'idle'
|
|
1830
|
+
dragPaths = []
|
|
1831
|
+
startRow = null
|
|
1832
|
+
rubber.classList.remove('on')
|
|
1833
|
+
rubber.style.cssText = ''
|
|
1834
|
+
delete document.body.dataset.boxing
|
|
1835
|
+
delete document.body.dataset.dragging
|
|
1836
|
+
if (ghost) { ghost.remove(); ghost = null }
|
|
1837
|
+
for (const node of list.querySelectorAll('.row.drag-src')) node.classList.remove('drag-src')
|
|
1838
|
+
if (stashCol) stashCol.classList.remove('drop-target')
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
list.addEventListener('pointerdown', event => {
|
|
1842
|
+
if (event.button !== 0) return
|
|
1843
|
+
const target = event.target
|
|
1844
|
+
if (target.closest('.add')) return // + 按钮有自己的 onClick
|
|
1845
|
+
if (target.closest('#browse-head')) return // 表头是排序按钮
|
|
1846
|
+
mode = 'pending'
|
|
1847
|
+
moved = false
|
|
1848
|
+
startX = event.clientX
|
|
1849
|
+
startY = event.clientY
|
|
1850
|
+
startRow = target.closest('.row[data-path]')
|
|
1851
|
+
const additive = event.shiftKey || event.ctrlKey || event.metaKey
|
|
1852
|
+
if (startRow) {
|
|
1853
|
+
const path = startRow.dataset.path
|
|
1854
|
+
if (additive) {
|
|
1855
|
+
// 按住 Ctrl/Shift:切换这一项的选中状态,其余保留
|
|
1856
|
+
if (state.browseSel.has(path)) state.browseSel.delete(path)
|
|
1857
|
+
else state.browseSel.add(path)
|
|
1858
|
+
} else if (!state.browseSel.has(path)) {
|
|
1859
|
+
// 普通左键:只留这一项(保持"选中=当前行"的直觉,也让单击后能直接拖走)
|
|
1860
|
+
state.browseSel = new Set([path])
|
|
1861
|
+
}
|
|
1862
|
+
paint()
|
|
1863
|
+
} else if (!additive) {
|
|
1864
|
+
state.browseSel = new Set()
|
|
1865
|
+
paint()
|
|
1866
|
+
}
|
|
1867
|
+
baseSel = Array.from(state.browseSel)
|
|
1868
|
+
// 注意:这里【不能】立刻 setPointerCapture。行是 <button>,指针被祖先列表捕获后
|
|
1869
|
+
// WebView2 不再派发行的 click → 真机鼠标点文件夹"没反应"(UIA 的 Invoke 直接派
|
|
1870
|
+
// DOM click,所以测试看着正常)。只有确认是拖动(moved 之后)才捕获,纯点击交还原生 click。
|
|
1871
|
+
})
|
|
1872
|
+
|
|
1873
|
+
list.addEventListener('pointermove', event => {
|
|
1874
|
+
if (mode === 'idle') return
|
|
1875
|
+
const dx = event.clientX - startX
|
|
1876
|
+
const dy = event.clientY - startY
|
|
1877
|
+
if (!moved) {
|
|
1878
|
+
if (Math.abs(dx) < THRESHOLD && Math.abs(dy) < THRESHOLD) return
|
|
1879
|
+
moved = true
|
|
1880
|
+
// 起手先一律进框选:列表里行是铺满的,几乎找不到空白区,
|
|
1881
|
+
// 若一按行就判成"拖到暂存区",用户就永远框不到东西。
|
|
1882
|
+
// 只有指针真的离开列表、且手里拿着选中项时,下一条 pointermove 才升级成拖拽。
|
|
1883
|
+
mode = 'box'
|
|
1884
|
+
document.body.dataset.boxing = 'true'
|
|
1885
|
+
rubber.classList.add('on')
|
|
1886
|
+
// 确认是拖动后才捕获指针:保证拖到暂存区上方松手时 pointerup 仍回到列表,
|
|
1887
|
+
// 同时不影响"纯点击不捕获",让行的 click 正常触发打开/预览。
|
|
1888
|
+
try { list.setPointerCapture(event.pointerId) } catch { /* 老 WebView 不支持就靠冒泡 */ }
|
|
1889
|
+
}
|
|
1890
|
+
// 指针已经拖出列表、且起手处有选中项 → 这一趟是想搬去暂存区,切成拖拽态。
|
|
1891
|
+
const inside = !!document.elementFromPoint(event.clientX, event.clientY)?.closest('#browse-list')
|
|
1892
|
+
if (mode === 'box' && !inside && startRow && state.browseSel.size) {
|
|
1893
|
+
mode = 'drag'
|
|
1894
|
+
// 框选过程改动过 browseSel,升级成拖拽时恢复成"起手那一刻选中的项",
|
|
1895
|
+
// 否则拖走的会是半路被框到的东西,不是用户按住的那几个。
|
|
1896
|
+
state.browseSel = new Set(baseSel)
|
|
1897
|
+
paint()
|
|
1898
|
+
dragPaths = Array.from(state.browseSel)
|
|
1899
|
+
delete document.body.dataset.boxing
|
|
1900
|
+
rubber.classList.remove('on')
|
|
1901
|
+
rubber.style.cssText = ''
|
|
1902
|
+
document.body.dataset.dragging = 'true'
|
|
1903
|
+
for (const node of rows()) {
|
|
1904
|
+
if (state.browseSel.has(node.dataset.path)) node.classList.add('drag-src')
|
|
1905
|
+
}
|
|
1906
|
+
ghost = el('div', { class: 'drag-ghost', text: dragPaths.length > 1 ? `搬运 ${dragPaths.length} 项` : '搬运到暂存区' })
|
|
1907
|
+
document.body.append(ghost)
|
|
1908
|
+
}
|
|
1909
|
+
if (mode === 'box') {
|
|
1910
|
+
const left = Math.min(startX, event.clientX)
|
|
1911
|
+
const top = Math.min(startY, event.clientY)
|
|
1912
|
+
const width = Math.abs(dx)
|
|
1913
|
+
const height = Math.abs(dy)
|
|
1914
|
+
rubber.style.left = `${left}px`
|
|
1915
|
+
rubber.style.top = `${top}px`
|
|
1916
|
+
rubber.style.width = `${width}px`
|
|
1917
|
+
rubber.style.height = `${height}px`
|
|
1918
|
+
boxSelect({ left, top, right: left + width, bottom: top + height },
|
|
1919
|
+
event.shiftKey || event.ctrlKey || event.metaKey)
|
|
1920
|
+
} else if (mode === 'drag' && ghost) {
|
|
1921
|
+
ghost.style.left = `${event.clientX + 14}px`
|
|
1922
|
+
ghost.style.top = `${event.clientY + 14}px`
|
|
1923
|
+
const over = stashCol && document.elementFromPoint(event.clientX, event.clientY)?.closest('#stash-col')
|
|
1924
|
+
if (stashCol) stashCol.classList.toggle('drop-target', !!over)
|
|
1925
|
+
}
|
|
1926
|
+
event.preventDefault()
|
|
1927
|
+
})
|
|
1928
|
+
|
|
1929
|
+
const finish = async event => {
|
|
1930
|
+
if (mode === 'idle') return
|
|
1931
|
+
const was = mode
|
|
1932
|
+
const paths = dragPaths.slice()
|
|
1933
|
+
const dropped = was === 'drag' && !!(
|
|
1934
|
+
stashCol && document.elementFromPoint(event.clientX, event.clientY)?.closest('#stash-col')
|
|
1935
|
+
)
|
|
1936
|
+
// 拖动/框选结束后浏览器还会补一个 click,必须吃掉它,
|
|
1937
|
+
// 否则把文件拖进暂存区的同时会在预览区把它打开一遍。
|
|
1938
|
+
if (moved) {
|
|
1939
|
+
suppressClick = true
|
|
1940
|
+
setTimeout(() => { suppressClick = false }, 0)
|
|
1941
|
+
}
|
|
1942
|
+
reset()
|
|
1943
|
+
if (dropped && paths.length) await addManyToStash(paths)
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
list.addEventListener('pointerup', event => { finish(event) })
|
|
1947
|
+
list.addEventListener('pointercancel', () => { reset() })
|
|
1948
|
+
|
|
1949
|
+
// 捕获阶段拦截那一次补发的 click(行是 button,click 会冒泡到列表)
|
|
1950
|
+
list.addEventListener('click', event => {
|
|
1951
|
+
if (!suppressClick) return
|
|
1952
|
+
suppressClick = false
|
|
1953
|
+
event.preventDefault()
|
|
1954
|
+
event.stopPropagation()
|
|
1955
|
+
}, true)
|
|
1956
|
+
}
|
|
1957
|
+
// 互关联②:暂存项 → 在右侧预览区打开。目录则打开该目录,文件则预览。
|
|
1958
|
+
async function previewStashItem(item) {
|
|
1959
|
+
if (item.missing) { toast('文件缺失,无法预览'); return }
|
|
1960
|
+
try {
|
|
1961
|
+
await openPath(item.path)
|
|
1962
|
+
} catch (error) {
|
|
1963
|
+
fail('预览失败:' + (error && error.message ? error.message : String(error)))
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
async function bootStash() {
|
|
1968
|
+
say('正在加载…')
|
|
1969
|
+
await refresh()
|
|
1970
|
+
say('就绪')
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
|
|
1974
|
+
// ================================================================ 外层分隔条(暂存栏 | 预览区)
|
|
1975
|
+
// 与内层 #split 同一套「起点 + 位移」算法:宽度 = 起点宽 + (当前X - 起点X)。
|
|
1976
|
+
// 用 --stash-w 控制左栏宽度并持久化到 localStorage(外层不做后端命令,简单可靠)。
|
|
1977
|
+
|
|
1978
|
+
const FM_MIN = 200, FM_MAX = 640, FM_DEFAULT = 300
|
|
1979
|
+
const clampFm = n => (!Number.isFinite(n) ? FM_DEFAULT : Math.min(FM_MAX, Math.max(FM_MIN, n)))
|
|
1980
|
+
const applyFmWidth = () => {
|
|
1981
|
+
document.documentElement.style.setProperty('--stash-w', state.stashW + 'px')
|
|
1982
|
+
const sp = $('fmsplit'); if (sp) sp.setAttribute('aria-valuenow', String(state.stashW))
|
|
1983
|
+
}
|
|
1984
|
+
const saveFm = () => { try { localStorage.setItem('fm.stashW', String(state.stashW)) } catch { /* 存储不可用时忽略 */ } }
|
|
1985
|
+
|
|
1986
|
+
function wireFmSplit() {
|
|
1987
|
+
try {
|
|
1988
|
+
const saved = Number(localStorage.getItem('fm.stashW'))
|
|
1989
|
+
if (Number.isFinite(saved) && saved > 0) state.stashW = clampFm(saved)
|
|
1990
|
+
} catch { /* 忽略 */ }
|
|
1991
|
+
applyFmWidth()
|
|
1992
|
+
const split = $('fmsplit')
|
|
1993
|
+
if (!split) return
|
|
1994
|
+
let dragging = false, startX = 0, startW = 0
|
|
1995
|
+
const down = event => {
|
|
1996
|
+
if (event.button !== undefined && event.button !== 0) return
|
|
1997
|
+
dragging = true; startX = event.clientX; startW = state.stashW
|
|
1998
|
+
split.setPointerCapture?.(event.pointerId)
|
|
1999
|
+
split.dataset.dragging = 'true'; document.body.dataset.resizing = 'true'
|
|
2000
|
+
event.preventDefault()
|
|
2001
|
+
}
|
|
2002
|
+
const move = event => {
|
|
2003
|
+
if (!dragging) return
|
|
2004
|
+
state.stashW = clampFm(startW + (event.clientX - startX))
|
|
2005
|
+
applyFmWidth()
|
|
2006
|
+
}
|
|
2007
|
+
const stop = () => {
|
|
2008
|
+
if (!dragging) return
|
|
2009
|
+
dragging = false; delete split.dataset.dragging; delete document.body.dataset.resizing
|
|
2010
|
+
saveFm()
|
|
2011
|
+
}
|
|
2012
|
+
split.addEventListener('pointerdown', down)
|
|
2013
|
+
split.addEventListener('pointermove', move)
|
|
2014
|
+
split.addEventListener('pointerup', stop)
|
|
2015
|
+
split.addEventListener('pointercancel', stop)
|
|
2016
|
+
split.addEventListener('lostpointercapture', stop)
|
|
2017
|
+
split.addEventListener('dblclick', () => { state.stashW = FM_DEFAULT; applyFmWidth(); saveFm() })
|
|
2018
|
+
split.addEventListener('keydown', event => {
|
|
2019
|
+
const step = event.shiftKey ? 48 : 16
|
|
2020
|
+
let next = null
|
|
2021
|
+
if (event.key === 'ArrowLeft') next = state.stashW - step
|
|
2022
|
+
else if (event.key === 'ArrowRight') next = state.stashW + step
|
|
2023
|
+
else if (event.key === 'Home') next = FM_DEFAULT
|
|
2024
|
+
if (next === null) return
|
|
2025
|
+
event.preventDefault()
|
|
2026
|
+
state.stashW = clampFm(next); applyFmWidth(); saveFm()
|
|
2027
|
+
})
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// ================================================================ 启动(两套都启动)
|
|
2031
|
+
|
|
2032
|
+
try { parent.postMessage({ type: 'fish:ready' }, '*') } catch { /* 宿主不可用时忽略 */ }
|
|
2033
|
+
new ResizeObserver(reportHeight).observe(document.documentElement)
|
|
2034
|
+
wireFmSplit()
|
|
2035
|
+
wireSplitter()
|
|
2036
|
+
wireBrowseSelect()
|
|
2037
|
+
drawSide()
|
|
2038
|
+
// 两套 boot 只读数据、不互斥;不能用 guard(它会因共享 state.busy 把第二个启动挡掉)。
|
|
2039
|
+
const bootSafe = fn => Promise.resolve().then(fn).catch(error => bad(error && error.message ? error.message : String(error)))
|
|
2040
|
+
bootSafe(bootPreview)
|
|
2041
|
+
bootSafe(bootStash)
|
|
2042
|
+
})()
|