@brftech/filex-core 0.39.1 → 0.40.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/filex-core.js +7167 -6884
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +49 -49
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +51 -11
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +30 -0
- package/src/components/ContextMenu.vue +18 -0
- package/src/components/QuickLook.vue +8 -4
- package/src/components/ShortcutSettings.vue +37 -0
- package/src/components/Toolbar.vue +19 -11
- package/src/composables/useKeyboardShortcuts.ts +122 -0
- package/src/index.ts +5 -0
- package/src/lib/themes.ts +96 -2
- package/src/locales/en.ts +19 -0
- package/src/locales/tr.ts +19 -0
- package/src/styles/base.css +26 -0
- package/src/styles/variables.css +6 -0
package/src/FileExplorer.vue
CHANGED
|
@@ -2029,9 +2029,39 @@ useKeyboardShortcuts(rootEl, {
|
|
|
2029
2029
|
onTabNext: () => nextTab(),
|
|
2030
2030
|
onTabPrev: () => prevTab(),
|
|
2031
2031
|
/* /wiring:d1 */
|
|
2032
|
+
/* tus:t1 — the menu verbs, routed through the SAME dispatcher the right-click
|
|
2033
|
+
* menu and the toolbar use (dispatchItemAction), so a key and a click cannot
|
|
2034
|
+
* drift apart. The ones that act on a selection do nothing without one, which
|
|
2035
|
+
* is what the menu does too. */
|
|
2036
|
+
onNewFolder: () => {
|
|
2037
|
+
showNewFolder.value = true;
|
|
2038
|
+
},
|
|
2039
|
+
onUpload: () => triggerUpload(),
|
|
2040
|
+
onRefresh: () => void load(),
|
|
2041
|
+
onDownload: () => void dispatchItemAction('download', shortcutTargets()),
|
|
2042
|
+
onPreview: () => void dispatchItemAction('preview', shortcutTargets()),
|
|
2043
|
+
onShare: () => void dispatchItemAction('access', shortcutTargets()),
|
|
2044
|
+
onTags: () => void dispatchItemAction('tags', shortcutTargets()),
|
|
2045
|
+
onConvert: () => void dispatchItemAction('convert', shortcutTargets()),
|
|
2046
|
+
onOpenTab: () => void dispatchItemAction('open-tab', shortcutTargets()),
|
|
2047
|
+
onCopyPath: () => {
|
|
2048
|
+
const n = shortcutTargets()[0];
|
|
2049
|
+
if (n) void onCopyPath(n.path);
|
|
2050
|
+
},
|
|
2051
|
+
onCopyId: () => void dispatchItemAction('copy-id', shortcutTargets()),
|
|
2052
|
+
onRestore: () => void dispatchItemAction('restore', shortcutTargets()),
|
|
2053
|
+
/* /tus:t1 */
|
|
2032
2054
|
hasSelection: () => !selection.isEmpty.value,
|
|
2033
2055
|
});
|
|
2034
2056
|
|
|
2057
|
+
/* tus:t1 — which rows a keyboard verb acts on: the active pane's selection when
|
|
2058
|
+
* the split pane has focus, otherwise the main listing's. Same rule the delete
|
|
2059
|
+
* and rename shortcuts have followed since wiring:d1. */
|
|
2060
|
+
function shortcutTargets(): FileNode[] {
|
|
2061
|
+
if (paneIsActive.value) return splitPaneRef.value?.selectedNodes() ?? [];
|
|
2062
|
+
return selection.nodes.value;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2035
2065
|
// --------------------------------------------------------------------
|
|
2036
2066
|
// Actions
|
|
2037
2067
|
// --------------------------------------------------------------------
|
|
@@ -10,6 +10,7 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
|
|
10
10
|
import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
|
|
11
11
|
import type { FileNode } from '../types/FileNode';
|
|
12
12
|
import { useLocale } from '../composables/useLocale';
|
|
13
|
+
import { menuShortcutHint } from '../composables/useKeyboardShortcuts';
|
|
13
14
|
|
|
14
15
|
export interface ContextAction {
|
|
15
16
|
key: string;
|
|
@@ -19,6 +20,12 @@ export interface ContextAction {
|
|
|
19
20
|
disabled?: boolean;
|
|
20
21
|
hidden?: boolean;
|
|
21
22
|
divider?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* tus:t1 — registry action id whose key this row should print. Optional:
|
|
25
|
+
* rows whose `key` already matches the registry (rename, delete, copy…)
|
|
26
|
+
* are resolved from the shared map and need nothing here.
|
|
27
|
+
*/
|
|
28
|
+
shortcutId?: string;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
const props = defineProps<{
|
|
@@ -48,6 +55,12 @@ const emit = defineEmits<{
|
|
|
48
55
|
|
|
49
56
|
const { t } = useLocale(() => props.locale); // bag:b4 — sheet aria labels need t()
|
|
50
57
|
|
|
58
|
+
/* tus:t1 — a row's key label. `shortcutId` wins when an embedder sets one;
|
|
59
|
+
* otherwise the row's own key is looked up in the shared menu map. */
|
|
60
|
+
function hintFor(a: ContextAction): string {
|
|
61
|
+
return menuShortcutHint(a.shortcutId ?? a.key);
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
const open = ref(false);
|
|
52
65
|
const x = ref(0);
|
|
53
66
|
const y = ref(0);
|
|
@@ -320,6 +333,7 @@ defineExpose({ show, hide });
|
|
|
320
333
|
>
|
|
321
334
|
<span v-if="a.icon" class="fe-ctx__icon" aria-hidden="true">{{ a.icon }}</span>
|
|
322
335
|
<span class="fe-ctx__label">{{ a.label }}</span>
|
|
336
|
+
<span v-if="hintFor(a)" class="fe-ctx__key" aria-hidden="true">{{ hintFor(a) }}</span>
|
|
323
337
|
</button>
|
|
324
338
|
</template>
|
|
325
339
|
</div>
|
|
@@ -347,6 +361,10 @@ defineExpose({ show, hide });
|
|
|
347
361
|
>
|
|
348
362
|
<span v-if="a.icon" class="fe-ctx__icon" aria-hidden="true">{{ a.icon }}</span>
|
|
349
363
|
<span class="fe-ctx__label">{{ a.label }}</span>
|
|
364
|
+
<!-- tus:t1 — the key that does this from the keyboard, read from
|
|
365
|
+
the registry so a remap reaches it. Empty for a verb with no
|
|
366
|
+
binding, and the span then does not render at all. -->
|
|
367
|
+
<span v-if="hintFor(a)" class="fe-ctx__key" aria-hidden="true">{{ hintFor(a) }}</span>
|
|
350
368
|
</button>
|
|
351
369
|
</template>
|
|
352
370
|
</div>
|
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
* mode), so every viewer the modal knows (image/video/pdf/office/code/
|
|
7
7
|
* 3D/archive/…) works in quick-look for free. What this layer adds:
|
|
8
8
|
*
|
|
9
|
-
* -
|
|
9
|
+
* - the quick-look key closes the peek again (Space by default — macOS
|
|
10
|
+
* Quick Look convention), and the open key promotes it into the full
|
|
11
|
+
* open flow (Enter by default, emit 'open-full'). Both come from the
|
|
12
|
+
* shortcut registry rather than from a comparison against the default
|
|
13
|
+
* key, so a remap reaches the overlay too
|
|
10
14
|
* - ← ↑ / → ↓ ask the host to move the selection; the host keeps the
|
|
11
|
-
* `file` prop in sync so the preview follows the selection
|
|
12
|
-
*
|
|
13
|
-
* - a small floating hint bar
|
|
15
|
+
* `file` prop in sync so the preview follows the selection. These are
|
|
16
|
+
* the peek's own arrows, not registry actions
|
|
17
|
+
* - a small floating hint bar naming those keys, read from the registry
|
|
14
18
|
*
|
|
15
19
|
* Keys are handled in window CAPTURE phase with stopPropagation so the
|
|
16
20
|
* global shortcut registry never double-handles them; events that
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
comboFromEvent,
|
|
25
25
|
effectiveCombo,
|
|
26
26
|
findShortcutConflict,
|
|
27
|
+
isReservedCombo,
|
|
27
28
|
resetAllShortcuts,
|
|
28
29
|
resetShortcut,
|
|
29
30
|
setShortcutOverride,
|
|
@@ -66,6 +67,8 @@ const anyOverridden = computed(() => list.value.some((s) => s.overridden));
|
|
|
66
67
|
|
|
67
68
|
const capturingId = ref<string | null>(null);
|
|
68
69
|
const conflict = ref<{ id: string; combo: string; conflictId: string; fixed: boolean } | null>(null);
|
|
70
|
+
/* tus:t1 — the user pressed something the browser keeps for itself. */
|
|
71
|
+
const reserved = ref<{ id: string; combo: string } | null>(null);
|
|
69
72
|
|
|
70
73
|
function labelOf(id: string): string {
|
|
71
74
|
const def = SHORTCUT_ACTIONS.find((a) => a.id === id);
|
|
@@ -74,6 +77,7 @@ function labelOf(id: string): string {
|
|
|
74
77
|
|
|
75
78
|
function beginCapture(id: string) {
|
|
76
79
|
conflict.value = null;
|
|
80
|
+
reserved.value = null;
|
|
77
81
|
capturingId.value = id;
|
|
78
82
|
}
|
|
79
83
|
|
|
@@ -84,6 +88,13 @@ function cancelCapture() {
|
|
|
84
88
|
function attemptAssign(id: string, combo: string) {
|
|
85
89
|
capturingId.value = null;
|
|
86
90
|
if (combo === effectiveCombo(id)) return; // unchanged
|
|
91
|
+
/* tus:t1 — a combo the browser takes before the page sees it would be a key
|
|
92
|
+
* that does nothing where the user is standing. Refuse it and say why,
|
|
93
|
+
* rather than storing a binding that never fires. */
|
|
94
|
+
if (isReservedCombo(combo)) {
|
|
95
|
+
reserved.value = { id, combo };
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
87
98
|
const clash = findShortcutConflict(combo, id);
|
|
88
99
|
if (clash) {
|
|
89
100
|
conflict.value = { id, combo, conflictId: clash.id, fixed: clash.fixed };
|
|
@@ -104,13 +115,19 @@ function dismissConflict() {
|
|
|
104
115
|
conflict.value = null;
|
|
105
116
|
}
|
|
106
117
|
|
|
118
|
+
function dismissReserved() {
|
|
119
|
+
reserved.value = null;
|
|
120
|
+
}
|
|
121
|
+
|
|
107
122
|
function onReset(id: string) {
|
|
108
123
|
conflict.value = null;
|
|
124
|
+
reserved.value = null;
|
|
109
125
|
resetShortcut(id);
|
|
110
126
|
}
|
|
111
127
|
|
|
112
128
|
function onResetAll() {
|
|
113
129
|
conflict.value = null;
|
|
130
|
+
reserved.value = null;
|
|
114
131
|
capturingId.value = null;
|
|
115
132
|
resetAllShortcuts();
|
|
116
133
|
}
|
|
@@ -145,6 +162,7 @@ watch(
|
|
|
145
162
|
window.removeEventListener('keydown', onWindowKeydown, true);
|
|
146
163
|
capturingId.value = null;
|
|
147
164
|
conflict.value = null;
|
|
165
|
+
reserved.value = null;
|
|
148
166
|
}
|
|
149
167
|
},
|
|
150
168
|
);
|
|
@@ -201,6 +219,14 @@ function onClose() {
|
|
|
201
219
|
</span>
|
|
202
220
|
<template v-for="(combo, i) in s.keys" v-else :key="combo">
|
|
203
221
|
<kbd class="fe-kbd">{{ combo }}</kbd>
|
|
222
|
+
<!-- tus:t1 — this combo never reaches a browser tab. Say so
|
|
223
|
+
on the row rather than letting the user discover it by
|
|
224
|
+
pressing the key and watching nothing happen. -->
|
|
225
|
+
<span
|
|
226
|
+
v-if="isReservedCombo(combo)"
|
|
227
|
+
class="fe-shortset__reserved"
|
|
228
|
+
:title="t('shortcuts.settings.reserved_title')"
|
|
229
|
+
>{{ t('shortcuts.settings.reserved_badge') }}</span>
|
|
204
230
|
<span v-if="i < s.keys.length - 1" class="fe-shortcuts__or" aria-hidden="true">/</span>
|
|
205
231
|
</template>
|
|
206
232
|
</template>
|
|
@@ -260,6 +286,17 @@ function onClose() {
|
|
|
260
286
|
</button>
|
|
261
287
|
</span>
|
|
262
288
|
</div>
|
|
289
|
+
<div v-if="reserved && reserved.id === s.id" class="fe-shortset__conflict" role="alert">
|
|
290
|
+
<span class="fe-shortset__conflict-msg">
|
|
291
|
+
<kbd class="fe-kbd">{{ reserved.combo }}</kbd>
|
|
292
|
+
{{ t('shortcuts.settings.reserved') }}
|
|
293
|
+
</span>
|
|
294
|
+
<span class="fe-shortset__conflict-actions">
|
|
295
|
+
<button type="button" class="fe-btn fe-shortset__btn" @click="dismissReserved">
|
|
296
|
+
{{ t('shortcuts.settings.conflict_cancel') }}
|
|
297
|
+
</button>
|
|
298
|
+
</span>
|
|
299
|
+
</div>
|
|
263
300
|
</div>
|
|
264
301
|
</section>
|
|
265
302
|
</div>
|
|
@@ -17,7 +17,7 @@ import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
|
|
|
17
17
|
import ContextMenu, { type ContextAction } from './ContextMenu.vue';
|
|
18
18
|
import ViewSwitcher from './ViewSwitcher.vue';
|
|
19
19
|
import { useLocale } from '../composables/useLocale';
|
|
20
|
-
import { eventMatchesShortcut, shortcutHint } from '../composables/useKeyboardShortcuts';
|
|
20
|
+
import { eventMatchesShortcut, menuShortcutHint, shortcutHint } from '../composables/useKeyboardShortcuts';
|
|
21
21
|
|
|
22
22
|
export type SelectionMode = 'none' | 'single-file' | 'single-dir' | 'multi';
|
|
23
23
|
|
|
@@ -389,6 +389,14 @@ const moreActions = computed<ContextAction[]>(() => {
|
|
|
389
389
|
* that key. */
|
|
390
390
|
const paletteCombo = computed(() => shortcutHint('palette'));
|
|
391
391
|
|
|
392
|
+
/* tus:t1 — a button's tooltip names its key. Same source as the right-click
|
|
393
|
+
* menu's key column, so the two can only ever say the same thing, and a verb
|
|
394
|
+
* with no binding gets its plain label back rather than an empty bracket. */
|
|
395
|
+
function withKey(label: string, key: string): string {
|
|
396
|
+
const combo = menuShortcutHint(key);
|
|
397
|
+
return combo ? `${label} (${combo})` : label;
|
|
398
|
+
}
|
|
399
|
+
|
|
392
400
|
const drivePlaceholder = computed(() =>
|
|
393
401
|
props.scopeLabel
|
|
394
402
|
? t('drive.search.placeholder', { scope: props.scopeLabel })
|
|
@@ -509,7 +517,7 @@ function onMoreSelect(a: ContextAction) {
|
|
|
509
517
|
v-if="canGoUp"
|
|
510
518
|
type="button"
|
|
511
519
|
class="fe-btn fe-btn--icon-only"
|
|
512
|
-
:title="t('toolbar.go_up')"
|
|
520
|
+
:title="withKey(t('toolbar.go_up'), 'go-up')"
|
|
513
521
|
:aria-label="t('toolbar.go_up')"
|
|
514
522
|
@click="emit('go-up')"
|
|
515
523
|
>
|
|
@@ -523,7 +531,7 @@ function onMoreSelect(a: ContextAction) {
|
|
|
523
531
|
v-if="shell !== 'drive' && mode === 'none' && !trashActive && !atVirtualRoot && canWrite !== false"
|
|
524
532
|
type="button"
|
|
525
533
|
class="fe-btn fe-btn--primary"
|
|
526
|
-
:title="t('toolbar.new_folder')"
|
|
534
|
+
:title="withKey(t('toolbar.new_folder'), 'new-folder')"
|
|
527
535
|
@click="emit('new-folder')"
|
|
528
536
|
>
|
|
529
537
|
<span class="fe-icon">📁</span>
|
|
@@ -537,7 +545,7 @@ function onMoreSelect(a: ContextAction) {
|
|
|
537
545
|
class="fe-btn fe-btn--fold"
|
|
538
546
|
:class="{ 'fe-btn--danger': a.danger, 'is-disabled': a.disabled }"
|
|
539
547
|
:disabled="a.disabled"
|
|
540
|
-
:title="a.label"
|
|
548
|
+
:title="withKey(a.label, a.key)"
|
|
541
549
|
@click="fire(a.key)"
|
|
542
550
|
>
|
|
543
551
|
<span class="fe-icon">{{ a.icon }}</span>
|
|
@@ -578,7 +586,7 @@ function onMoreSelect(a: ContextAction) {
|
|
|
578
586
|
v-if="pasteEnabled && mode === 'none' && !trashActive && !atVirtualRoot && canWrite !== false"
|
|
579
587
|
type="button"
|
|
580
588
|
class="fe-btn"
|
|
581
|
-
:title="t('ctx.paste')"
|
|
589
|
+
:title="withKey(t('ctx.paste'), 'paste')"
|
|
582
590
|
@click="fire('paste')"
|
|
583
591
|
>
|
|
584
592
|
<span class="fe-icon">📋</span>
|
|
@@ -641,7 +649,7 @@ function onMoreSelect(a: ContextAction) {
|
|
|
641
649
|
v-if="shell !== 'drive' && !atVirtualRoot && canWrite !== false"
|
|
642
650
|
type="button"
|
|
643
651
|
class="fe-btn fe-btn--icon-only"
|
|
644
|
-
:title="t('toolbar.upload')"
|
|
652
|
+
:title="withKey(t('toolbar.upload'), 'upload')"
|
|
645
653
|
:aria-label="t('toolbar.upload') /* wiring:c4 */"
|
|
646
654
|
@click="emit('upload')"
|
|
647
655
|
>
|
|
@@ -650,7 +658,7 @@ function onMoreSelect(a: ContextAction) {
|
|
|
650
658
|
<button
|
|
651
659
|
type="button"
|
|
652
660
|
class="fe-btn fe-btn--icon-only"
|
|
653
|
-
:title="t('toolbar.refresh')"
|
|
661
|
+
:title="withKey(t('toolbar.refresh'), 'refresh')"
|
|
654
662
|
:aria-label="t('toolbar.refresh') /* wiring:c4 */"
|
|
655
663
|
@click="emit('refresh')"
|
|
656
664
|
>
|
|
@@ -722,7 +730,7 @@ function onMoreSelect(a: ContextAction) {
|
|
|
722
730
|
class="fe-btn fe-btn--icon-only fe-toolbar__inspector"
|
|
723
731
|
:class="{ 'is-active': inspectorOpen }"
|
|
724
732
|
:aria-pressed="!!inspectorOpen"
|
|
725
|
-
:title="t('toolbar.inspector')"
|
|
733
|
+
:title="withKey(t('toolbar.inspector'), 'inspector')"
|
|
726
734
|
:aria-label="t('toolbar.inspector')"
|
|
727
735
|
@click="emit('toggle-inspector')"
|
|
728
736
|
>
|
|
@@ -922,7 +930,7 @@ function onMoreSelect(a: ContextAction) {
|
|
|
922
930
|
v-if="canGoUp"
|
|
923
931
|
type="button"
|
|
924
932
|
class="fe-btn fe-btn--icon-only"
|
|
925
|
-
:title="t('toolbar.go_up')"
|
|
933
|
+
:title="withKey(t('toolbar.go_up'), 'go-up')"
|
|
926
934
|
:aria-label="t('toolbar.go_up')"
|
|
927
935
|
@click="emit('go-up')"
|
|
928
936
|
>
|
|
@@ -935,7 +943,7 @@ function onMoreSelect(a: ContextAction) {
|
|
|
935
943
|
type="button"
|
|
936
944
|
class="fe-btn fe-btn--icon-only fe-toolbar__search-toggle"
|
|
937
945
|
:class="{ 'is-active': !!localSearch }"
|
|
938
|
-
:title="t('toolbar.search')"
|
|
946
|
+
:title="withKey(t('toolbar.search'), 'search')"
|
|
939
947
|
:aria-label="t('toolbar.search')"
|
|
940
948
|
@click="openSearch"
|
|
941
949
|
>
|
|
@@ -945,7 +953,7 @@ function onMoreSelect(a: ContextAction) {
|
|
|
945
953
|
v-if="!atVirtualRoot && canWrite !== false"
|
|
946
954
|
type="button"
|
|
947
955
|
class="fe-btn fe-btn--icon-only"
|
|
948
|
-
:title="t('toolbar.upload')"
|
|
956
|
+
:title="withKey(t('toolbar.upload'), 'upload')"
|
|
949
957
|
:aria-label="t('toolbar.upload')"
|
|
950
958
|
@click="emit('upload')"
|
|
951
959
|
>
|
|
@@ -47,6 +47,22 @@ export interface ShortcutHandlers {
|
|
|
47
47
|
onTabNext?: () => void; // Ctrl+Tab
|
|
48
48
|
onTabPrev?: () => void; // Ctrl+Shift+Tab
|
|
49
49
|
/* /wiring:d1 */
|
|
50
|
+
/* tus:t1 — the verbs that were only ever reachable from the menus. Every
|
|
51
|
+
* one of them is remappable like the rest; the ones that ship unbound are
|
|
52
|
+
* there so the user can give them a key, not because they do less. */
|
|
53
|
+
onNewFolder?: () => void; // Shift+N
|
|
54
|
+
onUpload?: () => void; // U
|
|
55
|
+
onRefresh?: () => void; // R
|
|
56
|
+
onDownload?: () => void; // D
|
|
57
|
+
onPreview?: () => void; // P
|
|
58
|
+
onShare?: () => void; // Shift+S
|
|
59
|
+
onTags?: () => void; // T
|
|
60
|
+
onConvert?: () => void; // (unbound)
|
|
61
|
+
onOpenTab?: () => void; // (unbound)
|
|
62
|
+
onCopyPath?: () => void; // (unbound)
|
|
63
|
+
onCopyId?: () => void; // (unbound)
|
|
64
|
+
onRestore?: () => void; // (unbound)
|
|
65
|
+
/* /tus:t1 */
|
|
50
66
|
hasSelection?: () => boolean; // disambiguates Backspace
|
|
51
67
|
}
|
|
52
68
|
|
|
@@ -77,6 +93,33 @@ export interface ShortcutActionDef {
|
|
|
77
93
|
inForms?: boolean;
|
|
78
94
|
}
|
|
79
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Combos the BROWSER takes before the page sees them. `preventDefault()`
|
|
98
|
+
* cannot stop these in an ordinary tab — Chrome and Firefox handle them at
|
|
99
|
+
* the window level — so an action bound to one of them only ever fires in
|
|
100
|
+
* the desktop app, an installed PWA or a kiosk window.
|
|
101
|
+
*
|
|
102
|
+
* They are not forbidden as DEFAULTS (the tab actions have shipped on
|
|
103
|
+
* Ctrl+T/W/Tab since wiring:d1 and work in the desktop app), but the
|
|
104
|
+
* settings modal refuses to let a user assign one: choosing a key that
|
|
105
|
+
* silently does nothing where you are standing is not a choice, it is a
|
|
106
|
+
* trap. `isReservedCombo` is the one place that list lives.
|
|
107
|
+
*/
|
|
108
|
+
const RESERVED_COMBOS = new Set([
|
|
109
|
+
'Ctrl+N', 'Ctrl+Shift+N',
|
|
110
|
+
'Ctrl+T', 'Ctrl+Shift+T',
|
|
111
|
+
'Ctrl+W', 'Ctrl+Shift+W',
|
|
112
|
+
'Ctrl+Tab', 'Ctrl+Shift+Tab',
|
|
113
|
+
'Ctrl+Q',
|
|
114
|
+
'Ctrl+Shift+I', 'Ctrl+Shift+J', 'Ctrl+Shift+C',
|
|
115
|
+
'F11', 'F12',
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
/** Does the browser eat this combo before the page can see it? */
|
|
119
|
+
export function isReservedCombo(combo: string): boolean {
|
|
120
|
+
return RESERVED_COMBOS.has(combo);
|
|
121
|
+
}
|
|
122
|
+
|
|
80
123
|
/** Registry order = help/settings display order. */
|
|
81
124
|
export const SHORTCUT_ACTIONS: ShortcutActionDef[] = [
|
|
82
125
|
// Navigation
|
|
@@ -109,6 +152,25 @@ export const SHORTCUT_ACTIONS: ShortcutActionDef[] = [
|
|
|
109
152
|
{ id: 'tab-next', defaultCombo: 'Ctrl+Tab', labelKey: 'shortcuts.tab_next', groupKey: 'shortcuts.group.tabs' },
|
|
110
153
|
{ id: 'tab-prev', defaultCombo: 'Ctrl+Shift+Tab', labelKey: 'shortcuts.tab_prev', groupKey: 'shortcuts.group.tabs' },
|
|
111
154
|
/* /wiring:d1 */
|
|
155
|
+
/* tus:t1 — the rest of the menu verbs. Until now these were reachable only
|
|
156
|
+
* by right-clicking, and the right-click menu named no keys at all, so 16 of
|
|
157
|
+
* the registry's actions were invisible unless somebody opened the `?` sheet
|
|
158
|
+
* on their own. The ones with no default are deliberate: they are rare
|
|
159
|
+
* enough that taking a key from the user by default is the wrong trade, and
|
|
160
|
+
* they are in the registry so the user can take one. */
|
|
161
|
+
{ id: 'new-folder', defaultCombo: 'Shift+N', labelKey: 'shortcuts.new_folder', groupKey: 'shortcuts.group.file' },
|
|
162
|
+
{ id: 'upload', defaultCombo: 'U', labelKey: 'shortcuts.upload', groupKey: 'shortcuts.group.file' },
|
|
163
|
+
{ id: 'refresh', defaultCombo: 'R', labelKey: 'shortcuts.refresh', groupKey: 'shortcuts.group.nav' },
|
|
164
|
+
{ id: 'download', defaultCombo: 'D', labelKey: 'shortcuts.download', groupKey: 'shortcuts.group.file' },
|
|
165
|
+
{ id: 'preview', defaultCombo: 'P', labelKey: 'shortcuts.preview', groupKey: 'shortcuts.group.nav' },
|
|
166
|
+
{ id: 'share', defaultCombo: 'Shift+S', labelKey: 'shortcuts.share', groupKey: 'shortcuts.group.file' },
|
|
167
|
+
{ id: 'tags', defaultCombo: 'T', labelKey: 'shortcuts.tags', groupKey: 'shortcuts.group.file' },
|
|
168
|
+
{ id: 'convert', defaultCombo: '', labelKey: 'shortcuts.convert', groupKey: 'shortcuts.group.file' },
|
|
169
|
+
{ id: 'open-tab', defaultCombo: '', labelKey: 'shortcuts.open_tab', groupKey: 'shortcuts.group.tabs' },
|
|
170
|
+
{ id: 'copy-path', defaultCombo: '', labelKey: 'shortcuts.copy_path', groupKey: 'shortcuts.group.file' },
|
|
171
|
+
{ id: 'copy-id', defaultCombo: '', labelKey: 'shortcuts.copy_id', groupKey: 'shortcuts.group.file' },
|
|
172
|
+
{ id: 'restore', defaultCombo: '', labelKey: 'shortcuts.restore', groupKey: 'shortcuts.group.file' },
|
|
173
|
+
/* /tus:t1 */
|
|
112
174
|
];
|
|
113
175
|
|
|
114
176
|
/** action id → ShortcutHandlers callback name. */
|
|
@@ -134,6 +196,19 @@ const HANDLER_KEY: Record<string, keyof ShortcutHandlers> = {
|
|
|
134
196
|
'tab-close': 'onTabClose',
|
|
135
197
|
'tab-next': 'onTabNext',
|
|
136
198
|
'tab-prev': 'onTabPrev',
|
|
199
|
+
/* tus:t1 */
|
|
200
|
+
'new-folder': 'onNewFolder',
|
|
201
|
+
upload: 'onUpload',
|
|
202
|
+
refresh: 'onRefresh',
|
|
203
|
+
download: 'onDownload',
|
|
204
|
+
preview: 'onPreview',
|
|
205
|
+
share: 'onShare',
|
|
206
|
+
tags: 'onTags',
|
|
207
|
+
convert: 'onConvert',
|
|
208
|
+
'open-tab': 'onOpenTab',
|
|
209
|
+
'copy-path': 'onCopyPath',
|
|
210
|
+
'copy-id': 'onCopyId',
|
|
211
|
+
restore: 'onRestore',
|
|
137
212
|
};
|
|
138
213
|
|
|
139
214
|
// --------------------------------------------------------------------
|
|
@@ -336,6 +411,53 @@ export function comboLabel(combo: string): string {
|
|
|
336
411
|
return isMacLike() ? combo.replace(/\bCtrl\b/g, '⌘') : combo;
|
|
337
412
|
}
|
|
338
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Menu action key → registry action id.
|
|
416
|
+
*
|
|
417
|
+
* The right-click menu, the toolbar and the keyboard all name the same verbs,
|
|
418
|
+
* but the menu's `key` and the registry's `id` are separate vocabularies and
|
|
419
|
+
* two of them genuinely differ (`access` is the share dialog, `details` is the
|
|
420
|
+
* inspector). One map, in one place: a second copy is a second chance to
|
|
421
|
+
* forget, and the symptom would be a menu row that silently stops naming its
|
|
422
|
+
* key while every other row keeps working.
|
|
423
|
+
*/
|
|
424
|
+
export const MENU_ACTION_SHORTCUTS: Record<string, string> = {
|
|
425
|
+
open: 'open',
|
|
426
|
+
'open-tab': 'open-tab',
|
|
427
|
+
preview: 'preview',
|
|
428
|
+
download: 'download',
|
|
429
|
+
convert: 'convert',
|
|
430
|
+
access: 'share',
|
|
431
|
+
details: 'inspector',
|
|
432
|
+
'copy-id': 'copy-id',
|
|
433
|
+
'copy-path': 'copy-path',
|
|
434
|
+
rename: 'rename',
|
|
435
|
+
cut: 'cut',
|
|
436
|
+
copy: 'copy',
|
|
437
|
+
paste: 'paste',
|
|
438
|
+
star: 'star',
|
|
439
|
+
tags: 'tags',
|
|
440
|
+
delete: 'delete',
|
|
441
|
+
restore: 'restore',
|
|
442
|
+
'new-folder': 'new-folder',
|
|
443
|
+
'toggle-hidden': 'toggle-hidden',
|
|
444
|
+
upload: 'upload',
|
|
445
|
+
refresh: 'refresh',
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* What a menu row or a toolbar button should print beside its label: the
|
|
450
|
+
* current combo for the verb it triggers, or '' when it has none (unbound, or
|
|
451
|
+
* not a registry action at all — `keep-local`, the E2E entries and so on).
|
|
452
|
+
*/
|
|
453
|
+
export function menuShortcutHint(menuKey: string): string {
|
|
454
|
+
/* Fall back to the key itself: the toolbar's own buttons (go-up, upload,
|
|
455
|
+
* refresh…) pass a registry id directly, and an id nothing knows returns ''
|
|
456
|
+
* rather than throwing. */
|
|
457
|
+
const id = MENU_ACTION_SHORTCUTS[menuKey] ?? menuKey;
|
|
458
|
+
return SHORTCUT_ACTIONS.some((a) => a.id === id) ? shortcutHint(id) : '';
|
|
459
|
+
}
|
|
460
|
+
|
|
339
461
|
/**
|
|
340
462
|
* The one way a hint may name a key.
|
|
341
463
|
*
|
package/src/index.ts
CHANGED
|
@@ -146,6 +146,11 @@ export {
|
|
|
146
146
|
comboLabel,
|
|
147
147
|
eventMatchesShortcut,
|
|
148
148
|
isMacLike,
|
|
149
|
+
/* tus:t1 — menu/toolbar rows name their key from the same registry, and the
|
|
150
|
+
* combos a browser tab never receives are declared in one place. */
|
|
151
|
+
menuShortcutHint,
|
|
152
|
+
MENU_ACTION_SHORTCUTS,
|
|
153
|
+
isReservedCombo,
|
|
149
154
|
} from './composables/useKeyboardShortcuts';
|
|
150
155
|
export type {
|
|
151
156
|
ShortcutActionDef,
|
package/src/lib/themes.ts
CHANGED
|
@@ -83,7 +83,11 @@ export const THEMES: ThemeDef[] = [
|
|
|
83
83
|
'--fe-text': '#1a1e27',
|
|
84
84
|
'--fe-text-muted': '#5a6475',
|
|
85
85
|
'--fe-text-on-primary': '#ffffff',
|
|
86
|
-
|
|
86
|
+
/* ⚠ Mirrors styles/variables.css. It drifted once: the stock primary was
|
|
87
|
+
* darkened to #2f6fe0 for WCAG AA against white text and this preview
|
|
88
|
+
* map kept the old #3b82f6, so the gallery card advertised a blue the
|
|
89
|
+
* product no longer used. */
|
|
90
|
+
'--fe-primary': '#2f6fe0',
|
|
87
91
|
'--fe-primary-hover': '#2563eb',
|
|
88
92
|
'--fe-danger': '#dc2626',
|
|
89
93
|
'--fe-danger-hover': '#b91c1c',
|
|
@@ -97,7 +101,9 @@ export const THEMES: ThemeDef[] = [
|
|
|
97
101
|
'--fe-border-strong': '#3a4453',
|
|
98
102
|
'--fe-text': '#e5e9f0',
|
|
99
103
|
'--fe-text-muted': '#8b95a7',
|
|
100
|
-
|
|
104
|
+
/* Dark ink, not white: white on #60a5fa measures 2.54 against the 4.5
|
|
105
|
+
* this file claims. Mirrors styles/variables.css. */
|
|
106
|
+
'--fe-text-on-primary': '#0f1419',
|
|
101
107
|
'--fe-primary': '#60a5fa',
|
|
102
108
|
'--fe-primary-hover': '#3b82f6',
|
|
103
109
|
'--fe-danger': '#f87171',
|
|
@@ -355,6 +361,94 @@ export const THEMES: ThemeDef[] = [
|
|
|
355
361
|
'--fe-font': 'ui-monospace, "SF Mono", Consolas, Menlo, monospace',
|
|
356
362
|
},
|
|
357
363
|
},
|
|
364
|
+
{
|
|
365
|
+
/**
|
|
366
|
+
* Drive — the palette of the end-user shell alfatm built on top of filex
|
|
367
|
+
* and put on a demo stand for review (GitHub #14). The drive profile's
|
|
368
|
+
* layout came out of their mockups; this is the colour and shape half of
|
|
369
|
+
* the same look, mapped onto our own tokens so every surface the explorer
|
|
370
|
+
* has — not just the drive shell — can wear it.
|
|
371
|
+
*
|
|
372
|
+
* Measured from their demo rather than eyeballed: 43 custom properties,
|
|
373
|
+
* of which the ones with a home here are the palette, the radii (6/8/10)
|
|
374
|
+
* and the per-file-type accents. Inter leads the font stack because that
|
|
375
|
+
* is what the demo uses; nothing is downloaded for it, so a machine
|
|
376
|
+
* without Inter installed falls back to the same system face as every
|
|
377
|
+
* other theme.
|
|
378
|
+
*/
|
|
379
|
+
id: 'drive',
|
|
380
|
+
nameKey: 'theme.name.drive',
|
|
381
|
+
light: {
|
|
382
|
+
'--fe-bg': '#ffffff',
|
|
383
|
+
'--fe-bg-elev': '#f7f8fb',
|
|
384
|
+
'--fe-bg-hover': '#f3f4f6',
|
|
385
|
+
'--fe-bg-selected': '#eef3ff',
|
|
386
|
+
'--fe-border': '#e5e7eb',
|
|
387
|
+
'--fe-border-strong': '#d1d5db',
|
|
388
|
+
'--fe-text': '#1f2937',
|
|
389
|
+
'--fe-text-muted': '#6b7280',
|
|
390
|
+
'--fe-text-on-primary': '#ffffff',
|
|
391
|
+
'--fe-primary': '#2f6ceb',
|
|
392
|
+
'--fe-primary-hover': '#2559c9',
|
|
393
|
+
'--fe-danger': '#dc2626',
|
|
394
|
+
'--fe-danger-hover': '#b91c1c',
|
|
395
|
+
'--fe-font': 'Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
|
396
|
+
'--fe-radius-sm': '6px',
|
|
397
|
+
'--fe-radius': '8px',
|
|
398
|
+
'--fe-radius-md': '10px',
|
|
399
|
+
'--fe-radius-lg': '12px',
|
|
400
|
+
'--fe-shadow': '0 20px 60px rgba(17, 24, 39, 0.18)',
|
|
401
|
+
'--fe-shadow-sm': '0 8px 24px rgba(17, 24, 39, 0.12)',
|
|
402
|
+
'--fe-icon-folder': '#f4b400',
|
|
403
|
+
'--fe-icon-image': '#2f6ceb',
|
|
404
|
+
'--fe-icon-video': '#7c3aed',
|
|
405
|
+
'--fe-icon-audio': '#0d9488',
|
|
406
|
+
'--fe-icon-pdf': '#dc2626',
|
|
407
|
+
'--fe-icon-doc': '#2f6ceb',
|
|
408
|
+
'--fe-icon-sheet': '#16a34a',
|
|
409
|
+
'--fe-icon-slides': '#ea580c',
|
|
410
|
+
'--fe-icon-archive': '#6b7280',
|
|
411
|
+
'--fe-icon-code': '#2f6ceb',
|
|
412
|
+
'--fe-icon-text': '#374151',
|
|
413
|
+
'--fe-icon-unknown': '#6b7280',
|
|
414
|
+
},
|
|
415
|
+
dark: {
|
|
416
|
+
'--fe-bg': '#15171c',
|
|
417
|
+
'--fe-bg-elev': '#1a1d23',
|
|
418
|
+
'--fe-bg-hover': '#1f232a',
|
|
419
|
+
'--fe-bg-selected': '#1c2740',
|
|
420
|
+
'--fe-border': '#2e333c',
|
|
421
|
+
'--fe-border-strong': '#3d444f',
|
|
422
|
+
'--fe-text': '#e6e8ec',
|
|
423
|
+
'--fe-text-muted': '#888f9b',
|
|
424
|
+
/* Dark ink on a light-blue button: white on #5b8cff measures 3.16, and
|
|
425
|
+
* the bar this file documents is 4.5. */
|
|
426
|
+
'--fe-text-on-primary': '#15171c',
|
|
427
|
+
'--fe-primary': '#5b8cff',
|
|
428
|
+
'--fe-primary-hover': '#7ba3ff',
|
|
429
|
+
'--fe-danger': '#f87171',
|
|
430
|
+
'--fe-danger-hover': '#ef4444',
|
|
431
|
+
'--fe-font': 'Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
|
432
|
+
'--fe-radius-sm': '6px',
|
|
433
|
+
'--fe-radius': '8px',
|
|
434
|
+
'--fe-radius-md': '10px',
|
|
435
|
+
'--fe-radius-lg': '12px',
|
|
436
|
+
'--fe-shadow': '0 20px 60px rgba(0, 0, 0, 0.6)',
|
|
437
|
+
'--fe-shadow-sm': '0 8px 24px rgba(0, 0, 0, 0.5)',
|
|
438
|
+
'--fe-icon-folder': '#f4b400',
|
|
439
|
+
'--fe-icon-image': '#4d7ff0',
|
|
440
|
+
'--fe-icon-video': '#9061f0',
|
|
441
|
+
'--fe-icon-audio': '#2dd4bf',
|
|
442
|
+
'--fe-icon-pdf': '#e05252',
|
|
443
|
+
'--fe-icon-doc': '#4d7ff0',
|
|
444
|
+
'--fe-icon-sheet': '#2eab63',
|
|
445
|
+
'--fe-icon-slides': '#fb923c',
|
|
446
|
+
'--fe-icon-archive': '#79808d',
|
|
447
|
+
'--fe-icon-code': '#4d7ff0',
|
|
448
|
+
'--fe-icon-text': '#5b6472',
|
|
449
|
+
'--fe-icon-unknown': '#79808d',
|
|
450
|
+
},
|
|
451
|
+
},
|
|
358
452
|
];
|
|
359
453
|
|
|
360
454
|
export function themeById(id: string | null | undefined): ThemeDef | undefined {
|
package/src/locales/en.ts
CHANGED
|
@@ -306,6 +306,7 @@ export const en: Record<string, string> = {
|
|
|
306
306
|
'theme.name.contrast': 'High Contrast',
|
|
307
307
|
'theme.name.gray': 'Soft Gray',
|
|
308
308
|
'theme.name.terminal': 'Terminal Green',
|
|
309
|
+
'theme.name.drive': 'Drive',
|
|
309
310
|
/* === wiring:c2 — customizable shortcuts + quick look === */
|
|
310
311
|
'shortcuts.quicklook': 'Quick look',
|
|
311
312
|
'shortcuts.customize': 'Customize',
|
|
@@ -395,6 +396,24 @@ export const en: Record<string, string> = {
|
|
|
395
396
|
'shortcuts.tab_close': 'Close tab',
|
|
396
397
|
'shortcuts.tab_next': 'Next tab',
|
|
397
398
|
'shortcuts.tab_prev': 'Previous tab',
|
|
399
|
+
/* tus:t1 — the menu verbs */
|
|
400
|
+
'shortcuts.new_folder': 'New folder',
|
|
401
|
+
'shortcuts.upload': 'Upload files',
|
|
402
|
+
'shortcuts.refresh': 'Refresh the listing',
|
|
403
|
+
'shortcuts.download': 'Download',
|
|
404
|
+
'shortcuts.preview': 'Preview',
|
|
405
|
+
'shortcuts.share': 'Share / permissions',
|
|
406
|
+
'shortcuts.tags': 'Tags',
|
|
407
|
+
'shortcuts.convert': 'Convert',
|
|
408
|
+
'shortcuts.open_tab': 'Open in a new tab',
|
|
409
|
+
'shortcuts.copy_path': 'Copy path',
|
|
410
|
+
'shortcuts.copy_id': 'Copy id',
|
|
411
|
+
'shortcuts.restore': 'Restore from trash',
|
|
412
|
+
'shortcuts.settings.reserved':
|
|
413
|
+
'is taken by the browser before the page sees it, so it would do nothing here. Pick another key.',
|
|
414
|
+
'shortcuts.settings.reserved_badge': 'Desktop app only',
|
|
415
|
+
'shortcuts.settings.reserved_title':
|
|
416
|
+
'The browser handles this combination itself. It fires in the desktop app and in an installed PWA, not in a browser tab.',
|
|
398
417
|
'cmd.tab_new': 'Open a new tab',
|
|
399
418
|
'cmd.split_toggle': 'Toggle split view',
|
|
400
419
|
'split.pane': 'Secondary pane',
|