@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.
Files changed (58) hide show
  1. package/dist/{ArchiveViewer-B_0IQExT.js → ArchiveViewer-EGgNqLPE.js} +2 -2
  2. package/dist/{ArchiveViewer-B_0IQExT.js.map → ArchiveViewer-EGgNqLPE.js.map} +1 -1
  3. package/dist/{CsvViewer-maMfUy5i.js → CsvViewer-BvriJ42M.js} +2 -2
  4. package/dist/{CsvViewer-maMfUy5i.js.map → CsvViewer-BvriJ42M.js.map} +1 -1
  5. package/dist/{DrawioViewer-DP_pXI00.js → DrawioViewer-B_bSPsWX.js} +2 -2
  6. package/dist/{DrawioViewer-DP_pXI00.js.map → DrawioViewer-B_bSPsWX.js.map} +1 -1
  7. package/dist/{EpubViewer-DSaqGnVx.js → EpubViewer-hl68h4gG.js} +2 -2
  8. package/dist/{EpubViewer-DSaqGnVx.js.map → EpubViewer-hl68h4gG.js.map} +1 -1
  9. package/dist/{IpynbViewer-BmdBanF1.js → IpynbViewer-lhwOjXK7.js} +2 -2
  10. package/dist/{IpynbViewer-BmdBanF1.js.map → IpynbViewer-lhwOjXK7.js.map} +1 -1
  11. package/dist/{MermaidViewer-Drlkl0Z5.js → MermaidViewer-H2Z6AYXg.js} +2 -2
  12. package/dist/{MermaidViewer-Drlkl0Z5.js.map → MermaidViewer-H2Z6AYXg.js.map} +1 -1
  13. package/dist/{PsdViewer-gftUJrsm.js → PsdViewer-Bsk-11yH.js} +2 -2
  14. package/dist/{PsdViewer-gftUJrsm.js.map → PsdViewer-Bsk-11yH.js.map} +1 -1
  15. package/dist/{TiffViewer-0Uv0-Lxh.js → TiffViewer-D-j9vycG.js} +2 -2
  16. package/dist/{TiffViewer-0Uv0-Lxh.js.map → TiffViewer-D-j9vycG.js.map} +1 -1
  17. package/dist/{Viewer3D-Gu3kUpxV.js → Viewer3D-Bcr70rTe.js} +2 -2
  18. package/dist/{Viewer3D-Gu3kUpxV.js.map → Viewer3D-Bcr70rTe.js.map} +1 -1
  19. package/dist/filex-core.js +50 -25
  20. package/dist/filex-core.umd.cjs +59 -58
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/index-C_ZSnk_I.js +11109 -0
  23. package/dist/index-C_ZSnk_I.js.map +1 -0
  24. package/dist/index.d.ts +597 -4
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +636 -15
  28. package/src/components/CommandPalette.vue +24 -1
  29. package/src/components/ContextMenu.vue +114 -8
  30. package/src/components/GalleryView.vue +214 -0
  31. package/src/components/GridView.vue +34 -1
  32. package/src/components/InspectorPanel.vue +170 -0
  33. package/src/components/ListView.vue +40 -6
  34. package/src/components/OnboardingTour.vue +332 -0
  35. package/src/components/OperationsCenter.vue +284 -0
  36. package/src/components/PendingOpsTray.vue +50 -66
  37. package/src/components/QuickLook.vue +142 -0
  38. package/src/components/SecondaryPane.vue +411 -0
  39. package/src/components/ShortcutSettings.vue +270 -0
  40. package/src/components/ShortcutsHelp.vue +30 -9
  41. package/src/components/TabBar.vue +174 -0
  42. package/src/components/ThemeGallery.vue +253 -0
  43. package/src/components/Toolbar.vue +75 -11
  44. package/src/components/UploadProgress.vue +49 -57
  45. package/src/composables/useFileApi.ts +53 -0
  46. package/src/composables/useKeyboardShortcuts.ts +340 -137
  47. package/src/composables/useOperations.ts +291 -0
  48. package/src/composables/useTabs.ts +222 -0
  49. package/src/index.ts +51 -0
  50. package/src/lib/dragGhost.ts +41 -0
  51. package/src/lib/themes.ts +505 -0
  52. package/src/locales/en.ts +125 -0
  53. package/src/locales/tr.ts +125 -0
  54. package/src/modals/Modal.vue +41 -5
  55. package/src/styles/base.css +1210 -0
  56. package/src/types/FileNode.ts +1 -1
  57. package/dist/index-BLocdbmc.js +0 -7993
  58. 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>