@brftech/filex-core 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ArchiveViewer-B_0IQExT.js → ArchiveViewer-EGgNqLPE.js} +2 -2
- package/dist/{ArchiveViewer-B_0IQExT.js.map → ArchiveViewer-EGgNqLPE.js.map} +1 -1
- package/dist/{CsvViewer-maMfUy5i.js → CsvViewer-BvriJ42M.js} +2 -2
- package/dist/{CsvViewer-maMfUy5i.js.map → CsvViewer-BvriJ42M.js.map} +1 -1
- package/dist/{DrawioViewer-DP_pXI00.js → DrawioViewer-B_bSPsWX.js} +2 -2
- package/dist/{DrawioViewer-DP_pXI00.js.map → DrawioViewer-B_bSPsWX.js.map} +1 -1
- package/dist/{EpubViewer-DSaqGnVx.js → EpubViewer-hl68h4gG.js} +2 -2
- package/dist/{EpubViewer-DSaqGnVx.js.map → EpubViewer-hl68h4gG.js.map} +1 -1
- package/dist/{IpynbViewer-BmdBanF1.js → IpynbViewer-lhwOjXK7.js} +2 -2
- package/dist/{IpynbViewer-BmdBanF1.js.map → IpynbViewer-lhwOjXK7.js.map} +1 -1
- package/dist/{MermaidViewer-Drlkl0Z5.js → MermaidViewer-H2Z6AYXg.js} +2 -2
- package/dist/{MermaidViewer-Drlkl0Z5.js.map → MermaidViewer-H2Z6AYXg.js.map} +1 -1
- package/dist/{PsdViewer-gftUJrsm.js → PsdViewer-Bsk-11yH.js} +2 -2
- package/dist/{PsdViewer-gftUJrsm.js.map → PsdViewer-Bsk-11yH.js.map} +1 -1
- package/dist/{TiffViewer-0Uv0-Lxh.js → TiffViewer-D-j9vycG.js} +2 -2
- package/dist/{TiffViewer-0Uv0-Lxh.js.map → TiffViewer-D-j9vycG.js.map} +1 -1
- package/dist/{Viewer3D-Gu3kUpxV.js → Viewer3D-Bcr70rTe.js} +2 -2
- package/dist/{Viewer3D-Gu3kUpxV.js.map → Viewer3D-Bcr70rTe.js.map} +1 -1
- package/dist/filex-core.js +50 -25
- package/dist/filex-core.umd.cjs +59 -58
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-C_ZSnk_I.js +11109 -0
- package/dist/index-C_ZSnk_I.js.map +1 -0
- package/dist/index.d.ts +597 -4
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +636 -15
- package/src/components/CommandPalette.vue +24 -1
- package/src/components/ContextMenu.vue +114 -8
- package/src/components/GalleryView.vue +214 -0
- package/src/components/GridView.vue +34 -1
- package/src/components/InspectorPanel.vue +170 -0
- package/src/components/ListView.vue +40 -6
- package/src/components/OnboardingTour.vue +332 -0
- package/src/components/OperationsCenter.vue +284 -0
- package/src/components/PendingOpsTray.vue +50 -66
- package/src/components/QuickLook.vue +142 -0
- package/src/components/SecondaryPane.vue +411 -0
- package/src/components/ShortcutSettings.vue +270 -0
- package/src/components/ShortcutsHelp.vue +30 -9
- package/src/components/TabBar.vue +174 -0
- package/src/components/ThemeGallery.vue +253 -0
- package/src/components/Toolbar.vue +75 -11
- package/src/components/UploadProgress.vue +49 -57
- package/src/composables/useFileApi.ts +53 -0
- package/src/composables/useKeyboardShortcuts.ts +340 -137
- package/src/composables/useOperations.ts +291 -0
- package/src/composables/useTabs.ts +222 -0
- package/src/index.ts +51 -0
- package/src/lib/dragGhost.ts +41 -0
- package/src/lib/themes.ts +505 -0
- package/src/locales/en.ts +125 -0
- package/src/locales/tr.ts +125 -0
- package/src/modals/Modal.vue +41 -5
- package/src/styles/base.css +1210 -0
- package/src/types/FileNode.ts +1 -1
- package/dist/index-BLocdbmc.js +0 -7993
- package/dist/index-BLocdbmc.js.map +0 -1
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SecondaryPane — wiring:d1 split görünümün sağ (ikincil) paneli.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately LIGHT: its own path + its own listing — fetched through
|
|
6
|
+
* the SAME FileApi instance the host already constructed (`api.index`,
|
|
7
|
+
* no copied fetch logic) — a small breadcrumb, double-click navigation,
|
|
8
|
+
* click selection and drag&drop in BOTH directions. No toolbar, no
|
|
9
|
+
* inspector, no upload: those belong to the main panel.
|
|
10
|
+
*
|
|
11
|
+
* Path semantics, confinement (`clamp`) and wire-form resolution
|
|
12
|
+
* (`qualify` / `toUser`) are injected from the host so the pane can
|
|
13
|
+
* never drift from the main panel's multi-storage / rootFloor rules.
|
|
14
|
+
*
|
|
15
|
+
* Cross-pane transfers only EMIT (`transfer`); the host decides
|
|
16
|
+
* move-vs-copy and runs the existing ops APIs. Unscoped styles
|
|
17
|
+
* (`fe-split*` in base.css) — webcomponent data-v rule.
|
|
18
|
+
*/
|
|
19
|
+
import { computed, ref } from 'vue';
|
|
20
|
+
import type { FileApi } from '../composables/useFileApi';
|
|
21
|
+
import type { FileNode } from '../types/FileNode';
|
|
22
|
+
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
23
|
+
import { useLocale } from '../composables/useLocale';
|
|
24
|
+
import { fileIconSvg } from '../lib/fileIcons';
|
|
25
|
+
|
|
26
|
+
// Same literals ListView/GridView already hardcode for the internal DnD
|
|
27
|
+
// channel; `-src` carries the origin directory so the host can move (and
|
|
28
|
+
// undo) correctly across panes.
|
|
29
|
+
const FE_DND_MIME = 'application/x-brf-files';
|
|
30
|
+
const FE_DND_SRC_MIME = 'application/x-brf-files-src';
|
|
31
|
+
|
|
32
|
+
const props = defineProps<{
|
|
33
|
+
api: FileApi;
|
|
34
|
+
/** Opening location, in the host's `currentPath` form. */
|
|
35
|
+
initialPath: string;
|
|
36
|
+
locale: LocaleCode;
|
|
37
|
+
/** Host's own qualify(): user path → wire `<adapter>://<rel>`. */
|
|
38
|
+
qualify: (p: string) => string;
|
|
39
|
+
/** Host's wire → user-path converter (multi-storage aware). */
|
|
40
|
+
toUser: (wire: string) => string;
|
|
41
|
+
/** Host's rootFloor clamp — keeps the pane inside the confine. */
|
|
42
|
+
clamp: (p: string) => string;
|
|
43
|
+
/** Label for the root crumb (adapter name / storage list). */
|
|
44
|
+
rootLabel: string;
|
|
45
|
+
/** rootFloor (user-path form, '' when unconfined) — trims crumbs. */
|
|
46
|
+
floor?: string;
|
|
47
|
+
/** Multi-storage mode: '' is the virtual drives root (no fetch). */
|
|
48
|
+
multiRoot?: boolean;
|
|
49
|
+
/** Synthesized storage rows for the virtual drives root. */
|
|
50
|
+
virtualRows?: () => FileNode[];
|
|
51
|
+
/** Active-panel highlight (keyboard target). */
|
|
52
|
+
active?: boolean;
|
|
53
|
+
}>();
|
|
54
|
+
|
|
55
|
+
const emit = defineEmits<{
|
|
56
|
+
(e: 'navigate', path: string): void;
|
|
57
|
+
(e: 'activate'): void;
|
|
58
|
+
(e: 'close'): void;
|
|
59
|
+
(e: 'open-tab', path: string): void;
|
|
60
|
+
(e: 'transfer', p: { sources: string[]; targetWire: string; originWire?: string }): void;
|
|
61
|
+
}>();
|
|
62
|
+
|
|
63
|
+
const { t, formatSize, nodeDisplayName } = useLocale(() => props.locale);
|
|
64
|
+
|
|
65
|
+
const path = ref<string>('');
|
|
66
|
+
const files = ref<FileNode[]>([]);
|
|
67
|
+
const loading = ref(false);
|
|
68
|
+
const error = ref('');
|
|
69
|
+
const selected = ref(new Set<string>());
|
|
70
|
+
|
|
71
|
+
function isStorageRow(n: FileNode): boolean {
|
|
72
|
+
return n.mime_type === 'inode/storage';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const atVirtualRoot = computed(
|
|
76
|
+
() => !!props.multiRoot && !(path.value ?? '').replace(/^\/+|\/+$/g, ''),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
async function loadPane(target?: string): Promise<void> {
|
|
80
|
+
const requested = props.clamp(target ?? path.value ?? '');
|
|
81
|
+
loading.value = true;
|
|
82
|
+
error.value = '';
|
|
83
|
+
try {
|
|
84
|
+
// Multi-storage virtual root: synthesize the drives list, no backend
|
|
85
|
+
// call — mirrors the main panel's load() branch.
|
|
86
|
+
if (props.multiRoot && !requested) {
|
|
87
|
+
files.value = props.virtualRows ? props.virtualRows() : [];
|
|
88
|
+
path.value = '';
|
|
89
|
+
selected.value = new Set();
|
|
90
|
+
emit('navigate', '');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const resp = await props.api.index(props.qualify(requested));
|
|
94
|
+
// Same internal-entry filter as the main listing.
|
|
95
|
+
files.value = (resp.files || []).filter((f) => {
|
|
96
|
+
if (f.path.includes('.thumbs')) return false;
|
|
97
|
+
if (f.path.includes('.versions') || f.basename === '.versions') return false;
|
|
98
|
+
if (f.basename === '.trash') return false;
|
|
99
|
+
if (f.basename === '.keepdir') return false;
|
|
100
|
+
return true;
|
|
101
|
+
});
|
|
102
|
+
path.value = props.toUser(resp.dirname);
|
|
103
|
+
selected.value = new Set();
|
|
104
|
+
emit('navigate', path.value);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
error.value = err instanceof Error ? err.message : String(err);
|
|
107
|
+
} finally {
|
|
108
|
+
loading.value = false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
void loadPane(props.initialPath ?? '');
|
|
113
|
+
|
|
114
|
+
// ------------------------------------------------------------------
|
|
115
|
+
// Breadcrumb (kırıntı)
|
|
116
|
+
// ------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
interface Crumb {
|
|
119
|
+
label: string;
|
|
120
|
+
target: string;
|
|
121
|
+
last: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const crumbs = computed<Crumb[]>(() => {
|
|
125
|
+
const clean = (path.value || '').replace(/^\/+|\/+$/g, '');
|
|
126
|
+
const segs = clean ? clean.split('/') : [];
|
|
127
|
+
const floor = (props.floor || '').replace(/^\/+|\/+$/g, '');
|
|
128
|
+
const out: Crumb[] = [];
|
|
129
|
+
let acc = '';
|
|
130
|
+
for (const s of segs) {
|
|
131
|
+
acc = acc ? `${acc}/${s}` : s;
|
|
132
|
+
// Confined embeds: hide the segments strictly ABOVE the floor — the
|
|
133
|
+
// floor segment itself acts as the pane's root crumb.
|
|
134
|
+
if (floor && !(acc === floor || acc.startsWith(floor + '/'))) continue;
|
|
135
|
+
out.push({ label: s, target: acc, last: false });
|
|
136
|
+
}
|
|
137
|
+
if (out.length > 0) out[out.length - 1].last = true;
|
|
138
|
+
return out;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const showRootCrumb = computed(() => !props.floor);
|
|
142
|
+
|
|
143
|
+
function crumbGo(target: string) {
|
|
144
|
+
emit('activate');
|
|
145
|
+
void loadPane(target);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ------------------------------------------------------------------
|
|
149
|
+
// Selection + navigation
|
|
150
|
+
// ------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
function onRowClick(n: FileNode, ev: MouseEvent) {
|
|
153
|
+
emit('activate');
|
|
154
|
+
const multi = ev.ctrlKey || ev.metaKey;
|
|
155
|
+
const next = new Set<string>(multi ? selected.value : []);
|
|
156
|
+
if (multi && next.has(n.path)) next.delete(n.path);
|
|
157
|
+
else next.add(n.path);
|
|
158
|
+
selected.value = next;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function onRowDbl(n: FileNode) {
|
|
162
|
+
if (n.type !== 'dir') return;
|
|
163
|
+
void loadPane(isStorageRow(n) ? n.path : props.toUser(n.path));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Middle-button mousedown must be cancelled on rows: in a scrollable body
|
|
167
|
+
// Chromium's autoscroll takes over and auxclick is never generated.
|
|
168
|
+
function onRowMiddleDown(ev: MouseEvent) {
|
|
169
|
+
if (ev.button === 1) ev.preventDefault();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Middle-click on a folder opens it in a NEW TAB (same convention as the
|
|
173
|
+
// main listing). stopPropagation keeps the host's delegated auxclick
|
|
174
|
+
// listener from double-handling it.
|
|
175
|
+
function onRowAux(n: FileNode, ev: MouseEvent) {
|
|
176
|
+
if (ev.button !== 1) return;
|
|
177
|
+
ev.preventDefault();
|
|
178
|
+
ev.stopPropagation();
|
|
179
|
+
if (n.type !== 'dir') return;
|
|
180
|
+
emit('open-tab', isStorageRow(n) ? n.path : props.toUser(n.path));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function clearSelection() {
|
|
184
|
+
selected.value = new Set();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ------------------------------------------------------------------
|
|
188
|
+
// Drag source
|
|
189
|
+
// ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
function onRowDragStart(n: FileNode, ev: DragEvent) {
|
|
192
|
+
if (!ev.dataTransfer) return;
|
|
193
|
+
if (isStorageRow(n) || atVirtualRoot.value) {
|
|
194
|
+
ev.preventDefault();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (!selected.value.has(n.path)) selected.value = new Set([n.path]);
|
|
198
|
+
const items = files.value
|
|
199
|
+
.filter((f) => selected.value.has(f.path) && !isStorageRow(f))
|
|
200
|
+
.map((f) => ({ path: f.path, basename: f.basename, type: f.type }));
|
|
201
|
+
if (items.length === 0) {
|
|
202
|
+
ev.preventDefault();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
ev.dataTransfer.setData(FE_DND_MIME, JSON.stringify(items));
|
|
206
|
+
ev.dataTransfer.setData(FE_DND_SRC_MIME, props.qualify(path.value));
|
|
207
|
+
ev.dataTransfer.setData('text/plain', items.map((i) => i.path).join('\n'));
|
|
208
|
+
ev.dataTransfer.effectAllowed = 'move';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ------------------------------------------------------------------
|
|
212
|
+
// Drop target (dir rows + pane background)
|
|
213
|
+
// ------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
const dropPath = ref<string | null>(null);
|
|
216
|
+
const dropBg = ref(false);
|
|
217
|
+
|
|
218
|
+
function acceptDrag(ev: DragEvent): boolean {
|
|
219
|
+
return !!ev.dataTransfer?.types.includes(FE_DND_MIME);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function handleDropPayload(ev: DragEvent, targetWire: string) {
|
|
223
|
+
const raw = ev.dataTransfer?.getData(FE_DND_MIME);
|
|
224
|
+
if (!raw || !targetWire) return;
|
|
225
|
+
let items: Array<{ path: string }> = [];
|
|
226
|
+
try {
|
|
227
|
+
items = JSON.parse(raw);
|
|
228
|
+
} catch {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const origin = ev.dataTransfer?.getData(FE_DND_SRC_MIME) || undefined;
|
|
232
|
+
const sources = items
|
|
233
|
+
.map((i) => i.path)
|
|
234
|
+
.filter((p) => p && p !== targetWire && !targetWire.startsWith(p + '/'));
|
|
235
|
+
if (sources.length === 0) return;
|
|
236
|
+
ev.preventDefault();
|
|
237
|
+
ev.stopPropagation();
|
|
238
|
+
emit('transfer', { sources, targetWire, originWire: origin });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function onRowDragOver(n: FileNode, ev: DragEvent) {
|
|
242
|
+
if (n.type !== 'dir' || isStorageRow(n)) return;
|
|
243
|
+
if (!acceptDrag(ev)) return;
|
|
244
|
+
ev.preventDefault();
|
|
245
|
+
ev.stopPropagation();
|
|
246
|
+
if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
|
|
247
|
+
dropPath.value = n.path;
|
|
248
|
+
dropBg.value = false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function onRowDragLeave(n: FileNode) {
|
|
252
|
+
if (dropPath.value === n.path) dropPath.value = null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function onRowDrop(n: FileNode, ev: DragEvent) {
|
|
256
|
+
dropPath.value = null;
|
|
257
|
+
dropBg.value = false;
|
|
258
|
+
if (n.type !== 'dir' || isStorageRow(n)) return;
|
|
259
|
+
if (!acceptDrag(ev)) return;
|
|
260
|
+
handleDropPayload(ev, n.path); // real rows are wire-qualified
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function onBgDragOver(ev: DragEvent) {
|
|
264
|
+
if (!acceptDrag(ev) || atVirtualRoot.value) return;
|
|
265
|
+
ev.preventDefault();
|
|
266
|
+
ev.stopPropagation();
|
|
267
|
+
if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
|
|
268
|
+
dropBg.value = true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function onBgDragLeave() {
|
|
272
|
+
dropBg.value = false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function onBgDrop(ev: DragEvent) {
|
|
276
|
+
dropBg.value = false;
|
|
277
|
+
dropPath.value = null;
|
|
278
|
+
if (!acceptDrag(ev) || atVirtualRoot.value) return;
|
|
279
|
+
handleDropPayload(ev, props.qualify(path.value));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ------------------------------------------------------------------
|
|
283
|
+
// Host API (keyboard routing when this pane is active)
|
|
284
|
+
// ------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
function goUp() {
|
|
287
|
+
const cur = (path.value ?? '').replace(/^\/+|\/+$/g, '');
|
|
288
|
+
const floor = (props.floor || '').replace(/^\/+|\/+$/g, '');
|
|
289
|
+
if (!cur || cur === floor) return;
|
|
290
|
+
const idx = cur.lastIndexOf('/');
|
|
291
|
+
void loadPane(idx === -1 ? '' : cur.slice(0, idx));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function selectAll() {
|
|
295
|
+
selected.value = new Set(files.value.filter((f) => !isStorageRow(f)).map((f) => f.path));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function selectedNodes(): FileNode[] {
|
|
299
|
+
return files.value.filter((f) => selected.value.has(f.path));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function openSelected() {
|
|
303
|
+
const n = selectedNodes()[0];
|
|
304
|
+
if (n && n.type === 'dir') onRowDbl(n);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function reload(): Promise<void> {
|
|
308
|
+
return loadPane();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function getPath(): string {
|
|
312
|
+
return path.value;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
defineExpose({ reload, goUp, selectAll, openSelected, selectedNodes, getPath });
|
|
316
|
+
|
|
317
|
+
function specialEmojiFor(n: FileNode): string | null {
|
|
318
|
+
return isStorageRow(n) ? '💾' : null;
|
|
319
|
+
}
|
|
320
|
+
</script>
|
|
321
|
+
|
|
322
|
+
<template>
|
|
323
|
+
<section
|
|
324
|
+
class="fe-split"
|
|
325
|
+
:class="{ 'fe-pane--focus': active }"
|
|
326
|
+
role="region"
|
|
327
|
+
:aria-label="t('split.pane')"
|
|
328
|
+
@pointerdown="emit('activate')"
|
|
329
|
+
>
|
|
330
|
+
<header class="fe-split__crumbs">
|
|
331
|
+
<button
|
|
332
|
+
v-if="showRootCrumb"
|
|
333
|
+
type="button"
|
|
334
|
+
class="fe-split__crumb"
|
|
335
|
+
:class="{ 'is-last': crumbs.length === 0 }"
|
|
336
|
+
:title="rootLabel"
|
|
337
|
+
@click="crumbGo('')"
|
|
338
|
+
>{{ rootLabel }}</button>
|
|
339
|
+
<template v-for="c in crumbs" :key="c.target">
|
|
340
|
+
<span class="fe-split__sep" aria-hidden="true">›</span>
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
class="fe-split__crumb"
|
|
344
|
+
:class="{ 'is-last': c.last }"
|
|
345
|
+
:title="c.label"
|
|
346
|
+
@click="crumbGo(c.target)"
|
|
347
|
+
>{{ c.label }}</button>
|
|
348
|
+
</template>
|
|
349
|
+
<span class="fe-split__spacer"></span>
|
|
350
|
+
<button
|
|
351
|
+
type="button"
|
|
352
|
+
class="fe-split__closebtn"
|
|
353
|
+
:aria-label="t('split.close')"
|
|
354
|
+
:title="t('split.close')"
|
|
355
|
+
@click="emit('close')"
|
|
356
|
+
>×</button>
|
|
357
|
+
</header>
|
|
358
|
+
|
|
359
|
+
<div
|
|
360
|
+
class="fe-split__body"
|
|
361
|
+
:class="{ 'is-dropover': dropBg }"
|
|
362
|
+
@click.self="clearSelection"
|
|
363
|
+
@dragover="onBgDragOver"
|
|
364
|
+
@dragleave="onBgDragLeave"
|
|
365
|
+
@drop="onBgDrop"
|
|
366
|
+
>
|
|
367
|
+
<div v-if="loading && files.length === 0" class="fe-split__state" role="status">
|
|
368
|
+
{{ t('loading') }}
|
|
369
|
+
</div>
|
|
370
|
+
<div v-else-if="error" class="fe-split__state">
|
|
371
|
+
<span>{{ t('split.error') }}</span>
|
|
372
|
+
<button type="button" class="fe-btn" @click="() => loadPane()">
|
|
373
|
+
{{ t('split.retry') }}
|
|
374
|
+
</button>
|
|
375
|
+
</div>
|
|
376
|
+
<div v-else-if="files.length === 0" class="fe-split__state">
|
|
377
|
+
{{ t('empty.folder') }}
|
|
378
|
+
</div>
|
|
379
|
+
<div v-else class="fe-split__list" role="listbox" aria-multiselectable="true">
|
|
380
|
+
<div
|
|
381
|
+
v-for="n in files"
|
|
382
|
+
:key="n.path"
|
|
383
|
+
class="fe-split__row"
|
|
384
|
+
:class="{
|
|
385
|
+
'is-selected': selected.has(n.path),
|
|
386
|
+
'is-dir': n.type === 'dir',
|
|
387
|
+
'is-droptarget': dropPath === n.path,
|
|
388
|
+
}"
|
|
389
|
+
role="option"
|
|
390
|
+
:aria-selected="selected.has(n.path) ? 'true' : 'false'"
|
|
391
|
+
tabindex="0"
|
|
392
|
+
draggable="true"
|
|
393
|
+
@click="onRowClick(n, $event)"
|
|
394
|
+
@dblclick="onRowDbl(n)"
|
|
395
|
+
@mousedown="onRowMiddleDown($event)"
|
|
396
|
+
@auxclick="onRowAux(n, $event)"
|
|
397
|
+
@dragstart="onRowDragStart(n, $event)"
|
|
398
|
+
@dragover="onRowDragOver(n, $event)"
|
|
399
|
+
@dragleave="onRowDragLeave(n)"
|
|
400
|
+
@drop="onRowDrop(n, $event)"
|
|
401
|
+
>
|
|
402
|
+
<span v-if="specialEmojiFor(n)" class="fe-split__icon" aria-hidden="true">{{ specialEmojiFor(n) }}</span>
|
|
403
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
|
|
404
|
+
<span v-else class="fe-split__icon" aria-hidden="true" v-html="fileIconSvg(n)"></span>
|
|
405
|
+
<span class="fe-split__name" :title="n.basename">{{ nodeDisplayName(n) }}</span>
|
|
406
|
+
<span class="fe-split__size">{{ n.type === 'dir' ? '' : formatSize(n.size) }}</span>
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
</section>
|
|
411
|
+
</template>
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ShortcutSettings — wiring:c2 keyboard-shortcut customization modal.
|
|
4
|
+
*
|
|
5
|
+
* Category-grouped rows from the shortcut registry; each row shows the
|
|
6
|
+
* action label + current combo badge(s) + "Change" (press-to-capture:
|
|
7
|
+
* the next key combination is recorded, Esc cancels) + a per-row
|
|
8
|
+
* "reset to default" and a header "reset all".
|
|
9
|
+
*
|
|
10
|
+
* Conflicts: assigning a combo that another action already uses turns
|
|
11
|
+
* the row into a red warning with two choices — unbind the old action
|
|
12
|
+
* and take the combo, or cancel. Combos reserved by fixed shortcuts
|
|
13
|
+
* (Backspace, Esc) can never be taken.
|
|
14
|
+
*
|
|
15
|
+
* While the modal is open a window-level CAPTURE listener swallows
|
|
16
|
+
* keydowns (except Esc/Tab) so experimenting with keys can't trigger
|
|
17
|
+
* explorer actions behind the modal; native button activation (Enter/
|
|
18
|
+
* Space on a focused button) is a default action and still works.
|
|
19
|
+
*/
|
|
20
|
+
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
|
21
|
+
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
22
|
+
import { useLocale } from '../composables/useLocale';
|
|
23
|
+
import {
|
|
24
|
+
comboFromEvent,
|
|
25
|
+
effectiveCombo,
|
|
26
|
+
findShortcutConflict,
|
|
27
|
+
resetAllShortcuts,
|
|
28
|
+
resetShortcut,
|
|
29
|
+
setShortcutOverride,
|
|
30
|
+
useShortcutList,
|
|
31
|
+
SHORTCUT_ACTIONS,
|
|
32
|
+
type ShortcutView,
|
|
33
|
+
} from '../composables/useKeyboardShortcuts';
|
|
34
|
+
import Modal from '../modals/Modal.vue';
|
|
35
|
+
|
|
36
|
+
const props = defineProps<{
|
|
37
|
+
open: boolean;
|
|
38
|
+
locale: LocaleCode;
|
|
39
|
+
theme?: 'light' | 'dark' | 'auto';
|
|
40
|
+
}>();
|
|
41
|
+
|
|
42
|
+
const emit = defineEmits<{
|
|
43
|
+
(e: 'close'): void;
|
|
44
|
+
}>();
|
|
45
|
+
|
|
46
|
+
const { t } = useLocale(() => props.locale);
|
|
47
|
+
|
|
48
|
+
const list = useShortcutList();
|
|
49
|
+
|
|
50
|
+
const groups = computed(() => {
|
|
51
|
+
const order: string[] = [];
|
|
52
|
+
const map = new Map<string, ShortcutView[]>();
|
|
53
|
+
for (const s of list.value) {
|
|
54
|
+
if (!map.has(s.groupKey)) {
|
|
55
|
+
map.set(s.groupKey, []);
|
|
56
|
+
order.push(s.groupKey);
|
|
57
|
+
}
|
|
58
|
+
map.get(s.groupKey)!.push(s);
|
|
59
|
+
}
|
|
60
|
+
return order.map((key) => ({ key, items: map.get(key)! }));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const anyOverridden = computed(() => list.value.some((s) => s.overridden));
|
|
64
|
+
|
|
65
|
+
// --- capture / conflict state ---
|
|
66
|
+
|
|
67
|
+
const capturingId = ref<string | null>(null);
|
|
68
|
+
const conflict = ref<{ id: string; combo: string; conflictId: string; fixed: boolean } | null>(null);
|
|
69
|
+
|
|
70
|
+
function labelOf(id: string): string {
|
|
71
|
+
const def = SHORTCUT_ACTIONS.find((a) => a.id === id);
|
|
72
|
+
return def ? t(def.labelKey) : id;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function beginCapture(id: string) {
|
|
76
|
+
conflict.value = null;
|
|
77
|
+
capturingId.value = id;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function cancelCapture() {
|
|
81
|
+
capturingId.value = null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function attemptAssign(id: string, combo: string) {
|
|
85
|
+
capturingId.value = null;
|
|
86
|
+
if (combo === effectiveCombo(id)) return; // unchanged
|
|
87
|
+
const clash = findShortcutConflict(combo, id);
|
|
88
|
+
if (clash) {
|
|
89
|
+
conflict.value = { id, combo, conflictId: clash.id, fixed: clash.fixed };
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
setShortcutOverride(id, combo);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function confirmConflictTake() {
|
|
96
|
+
const c = conflict.value;
|
|
97
|
+
if (!c || c.fixed) return;
|
|
98
|
+
setShortcutOverride(c.conflictId, ''); // unbind the old owner
|
|
99
|
+
setShortcutOverride(c.id, c.combo);
|
|
100
|
+
conflict.value = null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function dismissConflict() {
|
|
104
|
+
conflict.value = null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function onReset(id: string) {
|
|
108
|
+
conflict.value = null;
|
|
109
|
+
resetShortcut(id);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function onResetAll() {
|
|
113
|
+
conflict.value = null;
|
|
114
|
+
capturingId.value = null;
|
|
115
|
+
resetAllShortcuts();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- window capture listener (only while open) ---
|
|
119
|
+
|
|
120
|
+
function onWindowKeydown(e: KeyboardEvent) {
|
|
121
|
+
if (capturingId.value) {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
e.stopPropagation();
|
|
124
|
+
if (e.key === 'Escape') {
|
|
125
|
+
cancelCapture();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const combo = comboFromEvent(e);
|
|
129
|
+
if (!combo) return; // bare modifier — keep listening
|
|
130
|
+
attemptAssign(capturingId.value, combo);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Modal open but idle: let Esc (close) and Tab (focus nav) through,
|
|
134
|
+
// swallow everything else so explorer shortcuts can't fire behind us.
|
|
135
|
+
if (e.key === 'Escape' || e.key === 'Tab') return;
|
|
136
|
+
e.stopPropagation();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
watch(
|
|
140
|
+
() => props.open,
|
|
141
|
+
(open) => {
|
|
142
|
+
if (open) {
|
|
143
|
+
window.addEventListener('keydown', onWindowKeydown, true);
|
|
144
|
+
} else {
|
|
145
|
+
window.removeEventListener('keydown', onWindowKeydown, true);
|
|
146
|
+
capturingId.value = null;
|
|
147
|
+
conflict.value = null;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
onBeforeUnmount(() => window.removeEventListener('keydown', onWindowKeydown, true));
|
|
153
|
+
|
|
154
|
+
function onClose() {
|
|
155
|
+
if (capturingId.value) {
|
|
156
|
+
cancelCapture();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
emit('close');
|
|
160
|
+
}
|
|
161
|
+
</script>
|
|
162
|
+
|
|
163
|
+
<template>
|
|
164
|
+
<Modal :open="open" :title="t('shortcuts.settings.title')" size="lg" :theme="theme" @close="onClose">
|
|
165
|
+
<div class="fe-shortset">
|
|
166
|
+
<div class="fe-shortset__topbar">
|
|
167
|
+
<p class="fe-shortset__hint">{{ t('shortcuts.settings.hint') }}</p>
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
class="fe-btn fe-shortset__reset-all"
|
|
171
|
+
:disabled="!anyOverridden"
|
|
172
|
+
@click="onResetAll"
|
|
173
|
+
>
|
|
174
|
+
↺ {{ t('shortcuts.settings.reset_all') }}
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<section v-for="g in groups" :key="g.key" class="fe-shortset__group">
|
|
179
|
+
<h3 class="fe-shortcuts__heading">{{ t(g.key) }}</h3>
|
|
180
|
+
<div
|
|
181
|
+
v-for="s in g.items"
|
|
182
|
+
:key="s.id"
|
|
183
|
+
class="fe-shortset__item"
|
|
184
|
+
:class="{
|
|
185
|
+
'is-capturing': capturingId === s.id,
|
|
186
|
+
'is-conflict': conflict?.id === s.id,
|
|
187
|
+
}"
|
|
188
|
+
>
|
|
189
|
+
<div class="fe-shortset__row">
|
|
190
|
+
<span class="fe-shortset__label">
|
|
191
|
+
{{ t(s.labelKey) }}
|
|
192
|
+
<span v-if="s.overridden" class="fe-shortcuts__custom" :title="t('shortcuts.settings.customized')">●</span>
|
|
193
|
+
</span>
|
|
194
|
+
<span class="fe-shortset__keys">
|
|
195
|
+
<span v-if="capturingId === s.id" class="fe-shortset__capture" role="status">
|
|
196
|
+
{{ t('shortcuts.settings.capture') }}
|
|
197
|
+
</span>
|
|
198
|
+
<template v-else>
|
|
199
|
+
<span v-if="s.keys.length === 0" class="fe-shortcuts__unbound">
|
|
200
|
+
{{ t('shortcuts.settings.unbound') }}
|
|
201
|
+
</span>
|
|
202
|
+
<template v-for="(combo, i) in s.keys" v-else :key="combo">
|
|
203
|
+
<kbd class="fe-kbd">{{ combo }}</kbd>
|
|
204
|
+
<span v-if="i < s.keys.length - 1" class="fe-shortcuts__or" aria-hidden="true">/</span>
|
|
205
|
+
</template>
|
|
206
|
+
</template>
|
|
207
|
+
</span>
|
|
208
|
+
<span class="fe-shortset__actions">
|
|
209
|
+
<template v-if="s.customizable">
|
|
210
|
+
<button
|
|
211
|
+
v-if="capturingId !== s.id"
|
|
212
|
+
type="button"
|
|
213
|
+
class="fe-btn fe-shortset__btn"
|
|
214
|
+
@click="beginCapture(s.id)"
|
|
215
|
+
>
|
|
216
|
+
{{ t('shortcuts.settings.change') }}
|
|
217
|
+
</button>
|
|
218
|
+
<button
|
|
219
|
+
v-else
|
|
220
|
+
type="button"
|
|
221
|
+
class="fe-btn fe-shortset__btn"
|
|
222
|
+
@click="cancelCapture"
|
|
223
|
+
>
|
|
224
|
+
{{ t('shortcuts.settings.conflict_cancel') }}
|
|
225
|
+
</button>
|
|
226
|
+
<button
|
|
227
|
+
v-if="s.overridden"
|
|
228
|
+
type="button"
|
|
229
|
+
class="fe-btn fe-btn--icon-only fe-shortset__btn"
|
|
230
|
+
:title="t('shortcuts.settings.reset')"
|
|
231
|
+
:aria-label="t('shortcuts.settings.reset')"
|
|
232
|
+
@click="onReset(s.id)"
|
|
233
|
+
>
|
|
234
|
+
↺
|
|
235
|
+
</button>
|
|
236
|
+
</template>
|
|
237
|
+
<span v-else class="fe-shortset__fixed">{{ t('shortcuts.settings.fixed') }}</span>
|
|
238
|
+
</span>
|
|
239
|
+
</div>
|
|
240
|
+
<div v-if="conflict && conflict.id === s.id" class="fe-shortset__conflict" role="alert">
|
|
241
|
+
<span class="fe-shortset__conflict-msg">
|
|
242
|
+
<kbd class="fe-kbd">{{ conflict.combo }}</kbd>
|
|
243
|
+
{{
|
|
244
|
+
conflict.fixed
|
|
245
|
+
? t('shortcuts.settings.conflict_fixed', { name: labelOf(conflict.conflictId) })
|
|
246
|
+
: t('shortcuts.settings.conflict', { name: labelOf(conflict.conflictId) })
|
|
247
|
+
}}
|
|
248
|
+
</span>
|
|
249
|
+
<span class="fe-shortset__conflict-actions">
|
|
250
|
+
<button
|
|
251
|
+
v-if="!conflict.fixed"
|
|
252
|
+
type="button"
|
|
253
|
+
class="fe-btn fe-btn--danger fe-shortset__btn"
|
|
254
|
+
@click="confirmConflictTake"
|
|
255
|
+
>
|
|
256
|
+
{{ t('shortcuts.settings.conflict_take') }}
|
|
257
|
+
</button>
|
|
258
|
+
<button type="button" class="fe-btn fe-shortset__btn" @click="dismissConflict">
|
|
259
|
+
{{ t('shortcuts.settings.conflict_cancel') }}
|
|
260
|
+
</button>
|
|
261
|
+
</span>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
</section>
|
|
265
|
+
</div>
|
|
266
|
+
<template #actions>
|
|
267
|
+
<button type="button" class="fe-btn" @click="onClose">{{ t('viewer.close') }}</button>
|
|
268
|
+
</template>
|
|
269
|
+
</Modal>
|
|
270
|
+
</template>
|