@brftech/filex-core 0.4.2 → 0.5.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-DKW-HyXT.js} +2 -2
- package/dist/{ArchiveViewer-B_0IQExT.js.map → ArchiveViewer-DKW-HyXT.js.map} +1 -1
- package/dist/{CsvViewer-maMfUy5i.js → CsvViewer-D2HmP2yr.js} +2 -2
- package/dist/{CsvViewer-maMfUy5i.js.map → CsvViewer-D2HmP2yr.js.map} +1 -1
- package/dist/{DrawioViewer-DP_pXI00.js → DrawioViewer-DPNvOFCs.js} +2 -2
- package/dist/{DrawioViewer-DP_pXI00.js.map → DrawioViewer-DPNvOFCs.js.map} +1 -1
- package/dist/{EpubViewer-DSaqGnVx.js → EpubViewer-ByqjNuNL.js} +2 -2
- package/dist/{EpubViewer-DSaqGnVx.js.map → EpubViewer-ByqjNuNL.js.map} +1 -1
- package/dist/{IpynbViewer-BmdBanF1.js → IpynbViewer-2iONhbuJ.js} +2 -2
- package/dist/{IpynbViewer-BmdBanF1.js.map → IpynbViewer-2iONhbuJ.js.map} +1 -1
- package/dist/{MermaidViewer-Drlkl0Z5.js → MermaidViewer-CmiU7hIT.js} +2 -2
- package/dist/{MermaidViewer-Drlkl0Z5.js.map → MermaidViewer-CmiU7hIT.js.map} +1 -1
- package/dist/{PsdViewer-gftUJrsm.js → PsdViewer-Inhl2yN9.js} +2 -2
- package/dist/{PsdViewer-gftUJrsm.js.map → PsdViewer-Inhl2yN9.js.map} +1 -1
- package/dist/{TiffViewer-0Uv0-Lxh.js → TiffViewer-C2AFFEze.js} +2 -2
- package/dist/{TiffViewer-0Uv0-Lxh.js.map → TiffViewer-C2AFFEze.js.map} +1 -1
- package/dist/{Viewer3D-Gu3kUpxV.js → Viewer3D-YdW14q_n.js} +2 -2
- package/dist/{Viewer3D-Gu3kUpxV.js.map → Viewer3D-YdW14q_n.js.map} +1 -1
- package/dist/filex-core.js +47 -25
- package/dist/filex-core.umd.cjs +40 -40
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-eARevpz0.js +9952 -0
- package/dist/index-eARevpz0.js.map +1 -0
- package/dist/index.d.ts +363 -3
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +269 -1
- package/src/components/CommandPalette.vue +12 -0
- package/src/components/ContextMenu.vue +114 -8
- package/src/components/GridView.vue +33 -1
- package/src/components/ListView.vue +39 -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/ShortcutSettings.vue +270 -0
- package/src/components/ShortcutsHelp.vue +30 -9
- package/src/components/ThemeGallery.vue +253 -0
- package/src/components/Toolbar.vue +52 -6
- package/src/components/UploadProgress.vue +49 -57
- package/src/composables/useKeyboardShortcuts.ts +321 -137
- package/src/composables/useOperations.ts +291 -0
- package/src/index.ts +45 -0
- package/src/lib/dragGhost.ts +41 -0
- package/src/lib/themes.ts +505 -0
- package/src/locales/en.ts +92 -0
- package/src/locales/tr.ts +92 -0
- package/src/modals/Modal.vue +41 -5
- package/src/styles/base.css +672 -0
- package/dist/index-BLocdbmc.js +0 -7993
- package/dist/index-BLocdbmc.js.map +0 -1
package/src/FileExplorer.vue
CHANGED
|
@@ -50,6 +50,21 @@ import InspectorPanel from './components/InspectorPanel.vue'; /* koru:k1 */
|
|
|
50
50
|
import CommandPalette from './components/CommandPalette.vue';
|
|
51
51
|
import ShortcutsHelp from './components/ShortcutsHelp.vue';
|
|
52
52
|
/* /cila:c wiring */
|
|
53
|
+
/* wiring:c1 — tema galerisi */
|
|
54
|
+
import ThemeGallery from './components/ThemeGallery.vue';
|
|
55
|
+
import { useThemeState, applyThemeToEl, syncThemeStyle } from './lib/themes';
|
|
56
|
+
/* /wiring:c1 */
|
|
57
|
+
/* wiring:c2 — shortcut settings modal + Space quick-look overlay */
|
|
58
|
+
import ShortcutSettings from './components/ShortcutSettings.vue';
|
|
59
|
+
import QuickLook from './components/QuickLook.vue';
|
|
60
|
+
/* /wiring:c2 */
|
|
61
|
+
/* wiring:c3 — unified operations center */
|
|
62
|
+
import OperationsCenter from './components/OperationsCenter.vue';
|
|
63
|
+
import { useOperations } from './composables/useOperations';
|
|
64
|
+
/* /wiring:c3 */
|
|
65
|
+
/* wiring:c4 */
|
|
66
|
+
import OnboardingTour from './components/OnboardingTour.vue';
|
|
67
|
+
/* /wiring:c4 */
|
|
53
68
|
|
|
54
69
|
import NewFolderModal from './modals/NewFolderModal.vue';
|
|
55
70
|
import RenameModal from './modals/RenameModal.vue';
|
|
@@ -1099,6 +1114,7 @@ useKeyboardShortcuts(rootEl, {
|
|
|
1099
1114
|
showShortcutsHelp.value = true;
|
|
1100
1115
|
},
|
|
1101
1116
|
/* /cila:c wiring */
|
|
1117
|
+
onQuickLook: () => quickLookToggle() /* wiring:c2 */,
|
|
1102
1118
|
onToggleInspector: () => toggleInspector() /* koru:k1 */,
|
|
1103
1119
|
hasSelection: () => !selection.isEmpty.value,
|
|
1104
1120
|
});
|
|
@@ -2052,6 +2068,183 @@ async function onCopyPath(adapterPath: string) {
|
|
|
2052
2068
|
function buildAuthHeaders(extra: Record<string, string> = {}) {
|
|
2053
2069
|
return api.authHeadersSync({ ...extra });
|
|
2054
2070
|
}
|
|
2071
|
+
|
|
2072
|
+
/* === wiring:c1 — tema galerisi ===
|
|
2073
|
+
* Selected theme (shared module state, localStorage `filex.theme`) is applied
|
|
2074
|
+
* as inline `--fe-*` variables on the explorer root, resolved to the active
|
|
2075
|
+
* light/dark variant — plus a mirrored injected stylesheet for the surfaces
|
|
2076
|
+
* that re-declare tokens (teleported context menu, modal backdrops). Theme
|
|
2077
|
+
* selection is independent from the light/dark mode: the mode only picks
|
|
2078
|
+
* WHICH variant of the theme paints. */
|
|
2079
|
+
const showThemeGallery = ref(false);
|
|
2080
|
+
const { themeId: activeThemeId, setTheme: setActiveTheme } = useThemeState();
|
|
2081
|
+
// Resolved mode: explicit config wins, otherwise the OS preference — the same
|
|
2082
|
+
// logic variables.css encodes in CSS. The inline root variables beat every
|
|
2083
|
+
// stylesheet rule, so they must track this resolution at runtime.
|
|
2084
|
+
const themeMq =
|
|
2085
|
+
typeof window !== 'undefined' && window.matchMedia
|
|
2086
|
+
? window.matchMedia('(prefers-color-scheme: dark)')
|
|
2087
|
+
: undefined;
|
|
2088
|
+
const themeOsDark = ref(!!themeMq?.matches);
|
|
2089
|
+
function onThemeMqChange(e: MediaQueryListEvent) {
|
|
2090
|
+
themeOsDark.value = e.matches;
|
|
2091
|
+
}
|
|
2092
|
+
onMounted(() => themeMq?.addEventListener?.('change', onThemeMqChange));
|
|
2093
|
+
onBeforeUnmount(() => themeMq?.removeEventListener?.('change', onThemeMqChange));
|
|
2094
|
+
const themeResolvedDark = computed(
|
|
2095
|
+
() => props.config.theme === 'dark' || (props.config.theme !== 'light' && themeOsDark.value),
|
|
2096
|
+
);
|
|
2097
|
+
watch(
|
|
2098
|
+
[activeThemeId, themeResolvedDark, rootEl],
|
|
2099
|
+
() => {
|
|
2100
|
+
syncThemeStyle(activeThemeId.value);
|
|
2101
|
+
if (rootEl.value) applyThemeToEl(rootEl.value, activeThemeId.value, themeResolvedDark.value);
|
|
2102
|
+
},
|
|
2103
|
+
{ immediate: true },
|
|
2104
|
+
);
|
|
2105
|
+
/* === /wiring:c1 === */
|
|
2106
|
+
/* === wiring:c2 — shortcut settings modal + Space quick-look ===
|
|
2107
|
+
* quickLookTarget follows the selection while the peek is open: arrow
|
|
2108
|
+
* keys emitted by QuickLook move the selection here, and the watcher
|
|
2109
|
+
* below syncs the previewed file. Space toggles (registry action
|
|
2110
|
+
* `quicklook`); Enter promotes the peek into the normal open flow. */
|
|
2111
|
+
const showShortcutSettings = ref(false);
|
|
2112
|
+
const quickLookOpen = ref(false);
|
|
2113
|
+
const quickLookTarget = ref<FileNode | null>(null);
|
|
2114
|
+
|
|
2115
|
+
function quickLookToggle() {
|
|
2116
|
+
if (quickLookOpen.value) {
|
|
2117
|
+
quickLookOpen.value = false;
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
const sel = selection.nodes.value;
|
|
2121
|
+
const n = sel.length === 1 ? sel[0] : null;
|
|
2122
|
+
if (!n || n.type !== 'file' || n.basename === '.trash') return;
|
|
2123
|
+
quickLookTarget.value = n;
|
|
2124
|
+
quickLookOpen.value = true;
|
|
2125
|
+
void markRecent(n);
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
function quickLookNav(delta: number) {
|
|
2129
|
+
const onlyFiles = files.value.filter((f) => f.type === 'file');
|
|
2130
|
+
if (onlyFiles.length === 0) return;
|
|
2131
|
+
const cur = quickLookTarget.value;
|
|
2132
|
+
let idx = cur ? onlyFiles.findIndex((f) => f.path === cur.path) : -1;
|
|
2133
|
+
idx = idx === -1 ? (delta > 0 ? 0 : onlyFiles.length - 1) : (idx + delta + onlyFiles.length) % onlyFiles.length;
|
|
2134
|
+
const next = onlyFiles[idx];
|
|
2135
|
+
if (!next || next.path === cur?.path) return;
|
|
2136
|
+
selection.click(next.path);
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
function quickLookOpenFull() {
|
|
2140
|
+
const n = quickLookTarget.value;
|
|
2141
|
+
quickLookOpen.value = false;
|
|
2142
|
+
if (n) openNode(n);
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
watch(
|
|
2146
|
+
() => selection.nodes.value,
|
|
2147
|
+
(nodes) => {
|
|
2148
|
+
if (!quickLookOpen.value) return;
|
|
2149
|
+
const n = nodes.length === 1 && nodes[0].type === 'file' ? nodes[0] : null;
|
|
2150
|
+
if (n && n.path !== quickLookTarget.value?.path) {
|
|
2151
|
+
quickLookTarget.value = n;
|
|
2152
|
+
void markRecent(n);
|
|
2153
|
+
}
|
|
2154
|
+
},
|
|
2155
|
+
);
|
|
2156
|
+
/* === /wiring:c2 === */
|
|
2157
|
+
/* wiring:c3 — operations center store + failed-upload retry */
|
|
2158
|
+
const opsCenter = useOperations();
|
|
2159
|
+
|
|
2160
|
+
/**
|
|
2161
|
+
* Retry a failed upload from the operations center. The failed row is
|
|
2162
|
+
* already retired by the store; re-run the upload against the job's ORIGINAL
|
|
2163
|
+
* target folder (the user may have navigated away since) via the legacy
|
|
2164
|
+
* single-POST path — works for any storage / size, no chunked precondition.
|
|
2165
|
+
*/
|
|
2166
|
+
function retryUploadJob(job: UploadJob) {
|
|
2167
|
+
uploadJobs.value = uploadJobs.value.filter((j) => j.id !== job.id);
|
|
2168
|
+
const file = job.file;
|
|
2169
|
+
const target = job.path || qualify(currentPath.value);
|
|
2170
|
+
const id = crypto.randomUUID();
|
|
2171
|
+
uploadJobs.value = [
|
|
2172
|
+
...uploadJobs.value,
|
|
2173
|
+
{ id, file, path: target, totalBytes: file.size, uploadedBytes: 0, percent: 0, status: 'uploading', cancel() {} },
|
|
2174
|
+
];
|
|
2175
|
+
const patchRetry = (p: Partial<UploadJob>) => {
|
|
2176
|
+
const idx = uploadJobs.value.findIndex((j) => j.id === id);
|
|
2177
|
+
if (idx === -1) return;
|
|
2178
|
+
const next = [...uploadJobs.value];
|
|
2179
|
+
next[idx] = { ...next[idx], ...p };
|
|
2180
|
+
uploadJobs.value = next;
|
|
2181
|
+
};
|
|
2182
|
+
api
|
|
2183
|
+
.uploadMultipart(target, [file], (percent) => {
|
|
2184
|
+
patchRetry({ percent, uploadedBytes: Math.round((percent / 100) * file.size) });
|
|
2185
|
+
emit('upload-progress', { uploadId: id, percent, done: percent >= 100 });
|
|
2186
|
+
})
|
|
2187
|
+
.then(() => {
|
|
2188
|
+
patchRetry({ percent: 100, uploadedBytes: file.size, status: 'done' });
|
|
2189
|
+
emit('upload-progress', { uploadId: id, percent: 100, done: true });
|
|
2190
|
+
void load();
|
|
2191
|
+
})
|
|
2192
|
+
.catch((err: Error) => {
|
|
2193
|
+
patchRetry({ status: 'error', error: err.message });
|
|
2194
|
+
emit('error', { message: err.message, context: { op: 'upload-retry', file: file.name } });
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
/* /wiring:c3 */
|
|
2198
|
+
/* === wiring:c4 — onboarding coach-mark tour ===
|
|
2199
|
+
* First mount with no `filex.tourDone` flag auto-starts the tour (short
|
|
2200
|
+
* delay so the listing/toolbar are laid out). Closing it — finished OR
|
|
2201
|
+
* skipped — stamps the flag; "Turu tekrar başlat" re-opens it any time.
|
|
2202
|
+
* Restart arrives as a bubbled `fe:tour-restart` CustomEvent from the
|
|
2203
|
+
* Toolbar overflow menu, so no extra prop/emit threading through the
|
|
2204
|
+
* shared component tags is needed. */
|
|
2205
|
+
const TOUR_LS_KEY = 'filex.tourDone';
|
|
2206
|
+
const showTour = ref(false);
|
|
2207
|
+
let tourTimer: ReturnType<typeof setTimeout> | undefined;
|
|
2208
|
+
|
|
2209
|
+
function tourAlreadyDone(): boolean {
|
|
2210
|
+
try {
|
|
2211
|
+
return localStorage.getItem(TOUR_LS_KEY) === '1';
|
|
2212
|
+
} catch {
|
|
2213
|
+
return true; // no storage → never auto-nag
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
function startTour() {
|
|
2218
|
+
showTour.value = true;
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
function onTourClose() {
|
|
2222
|
+
showTour.value = false;
|
|
2223
|
+
try {
|
|
2224
|
+
localStorage.setItem(TOUR_LS_KEY, '1');
|
|
2225
|
+
} catch {
|
|
2226
|
+
/* private mode / quota */
|
|
2227
|
+
}
|
|
2228
|
+
rootEl.value?.focus();
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function onTourRestartEvent() {
|
|
2232
|
+
startTour();
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
onMounted(() => {
|
|
2236
|
+
rootEl.value?.addEventListener('fe:tour-restart', onTourRestartEvent);
|
|
2237
|
+
if (!tourAlreadyDone()) {
|
|
2238
|
+
tourTimer = setTimeout(() => {
|
|
2239
|
+
if (!showTour.value) startTour();
|
|
2240
|
+
}, 900);
|
|
2241
|
+
}
|
|
2242
|
+
});
|
|
2243
|
+
onBeforeUnmount(() => {
|
|
2244
|
+
if (tourTimer) clearTimeout(tourTimer);
|
|
2245
|
+
rootEl.value?.removeEventListener('fe:tour-restart', onTourRestartEvent);
|
|
2246
|
+
});
|
|
2247
|
+
/* === /wiring:c4 === */
|
|
2055
2248
|
</script>
|
|
2056
2249
|
|
|
2057
2250
|
<template>
|
|
@@ -2089,9 +2282,11 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
|
|
|
2089
2282
|
:theme="config.theme || 'auto' /* bag:b4 */"
|
|
2090
2283
|
:inspector-open="showInspector /* koru:k1 */"
|
|
2091
2284
|
@toggle-inspector="toggleInspector /* koru:k1 */"
|
|
2285
|
+
@open-theme="showThemeGallery = true /* wiring:c1 */"
|
|
2092
2286
|
@update:view-mode="viewMode = $event"
|
|
2093
2287
|
@update:search-query="searchQuery = $event"
|
|
2094
2288
|
@update:density="density = $event"
|
|
2289
|
+
@open-shortcut-settings="showShortcutSettings = true /* wiring:c2 */"
|
|
2095
2290
|
@new-folder="showNewFolder = true"
|
|
2096
2291
|
@upload="triggerUpload"
|
|
2097
2292
|
@refresh="() => load()"
|
|
@@ -2204,12 +2399,19 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
|
|
|
2204
2399
|
<path d="M24 88h72" stroke-dasharray="3 5" />
|
|
2205
2400
|
</svg>
|
|
2206
2401
|
<p class="fe-state__title">{{ t('error.title') }}</p>
|
|
2207
|
-
|
|
2402
|
+
<!-- wiring:c4 — friendly hint + collapsible technical detail; the raw
|
|
2403
|
+
error message used to sit in the hint slot and read like UI copy. -->
|
|
2404
|
+
<p class="fe-state__hint">{{ t('error.hint') }}</p>
|
|
2208
2405
|
<div class="fe-state__actions">
|
|
2209
2406
|
<button type="button" class="fe-btn fe-btn--primary" @click="retryLoad">
|
|
2210
2407
|
{{ t('error.retry') }}
|
|
2211
2408
|
</button>
|
|
2212
2409
|
</div>
|
|
2410
|
+
<details class="fe-state__details">
|
|
2411
|
+
<summary class="fe-state__details-summary">{{ t('error.details') }}</summary>
|
|
2412
|
+
<pre class="fe-state__details-pre">{{ loadError }}</pre>
|
|
2413
|
+
</details>
|
|
2414
|
+
<!-- /wiring:c4 -->
|
|
2213
2415
|
</div>
|
|
2214
2416
|
<!-- Search with zero hits — its own message, not "folder is empty". -->
|
|
2215
2417
|
<div v-else-if="!loading && files.length === 0 && searchQuery" class="fe-state">
|
|
@@ -2345,19 +2547,33 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
|
|
|
2345
2547
|
</div>
|
|
2346
2548
|
</div>
|
|
2347
2549
|
|
|
2550
|
+
<!-- wiring:c3 — unified operations center. UploadProgress + PendingOpsTray
|
|
2551
|
+
no longer draw their own corner UIs: they are renderless publishers
|
|
2552
|
+
feeding the opsCenter store; the single visible surface is the
|
|
2553
|
+
OperationsCenter badge + panel below. -->
|
|
2348
2554
|
<UploadProgress
|
|
2349
2555
|
:jobs="uploadJobs"
|
|
2350
2556
|
:locale="locale"
|
|
2557
|
+
:center="opsCenter"
|
|
2351
2558
|
@cancel="onCancelUpload"
|
|
2352
2559
|
@dismiss="onDismissUpload"
|
|
2560
|
+
@retry="retryUploadJob"
|
|
2353
2561
|
/>
|
|
2354
2562
|
|
|
2355
2563
|
<PendingOpsTray
|
|
2356
2564
|
:ops="pendingOps.ops.value"
|
|
2357
2565
|
:locale="locale"
|
|
2566
|
+
:center="opsCenter"
|
|
2358
2567
|
@dismiss="(id) => pendingOps.dismiss(id)"
|
|
2359
2568
|
/>
|
|
2360
2569
|
|
|
2570
|
+
<OperationsCenter
|
|
2571
|
+
:center="opsCenter"
|
|
2572
|
+
:locale="locale"
|
|
2573
|
+
:narrow="isNarrow"
|
|
2574
|
+
/>
|
|
2575
|
+
<!-- /wiring:c3 -->
|
|
2576
|
+
|
|
2361
2577
|
<!-- bag:b4 — narrow-mode upload FAB (hidden in trash / read-only /
|
|
2362
2578
|
virtual root; PendingOpsTray+UploadProgress shift up via CSS). -->
|
|
2363
2579
|
<button
|
|
@@ -2534,14 +2750,30 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
|
|
|
2534
2750
|
@open-trash="loadTrash"
|
|
2535
2751
|
@refresh="() => load()"
|
|
2536
2752
|
@go-up="goUp"
|
|
2753
|
+
@open-theme="showThemeGallery = true /* wiring:int */"
|
|
2754
|
+
@open-shortcut-settings="showShortcutSettings = true /* wiring:int */"
|
|
2755
|
+
@start-tour="startTour() /* wiring:int */"
|
|
2537
2756
|
/>
|
|
2538
2757
|
<ShortcutsHelp
|
|
2539
2758
|
:open="showShortcutsHelp"
|
|
2540
2759
|
:locale="locale"
|
|
2541
2760
|
@close="showShortcutsHelp = false"
|
|
2761
|
+
@customize="showShortcutsHelp = false; showShortcutSettings = true /* wiring:c2 */"
|
|
2542
2762
|
/>
|
|
2543
2763
|
<!-- /cila:c wiring -->
|
|
2544
2764
|
|
|
2765
|
+
<!-- wiring:c1 — tema galerisi -->
|
|
2766
|
+
<ThemeGallery
|
|
2767
|
+
:open="showThemeGallery"
|
|
2768
|
+
:locale="locale"
|
|
2769
|
+
:theme="config.theme || 'auto'"
|
|
2770
|
+
:dark="themeResolvedDark"
|
|
2771
|
+
:current="activeThemeId"
|
|
2772
|
+
@close="showThemeGallery = false"
|
|
2773
|
+
@select="setActiveTheme"
|
|
2774
|
+
/>
|
|
2775
|
+
<!-- /wiring:c1 -->
|
|
2776
|
+
|
|
2545
2777
|
<input
|
|
2546
2778
|
ref="fileInputEl"
|
|
2547
2779
|
type="file"
|
|
@@ -2567,6 +2799,42 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
|
|
|
2567
2799
|
>{{ toast.actionLabel }}</button>
|
|
2568
2800
|
</div>
|
|
2569
2801
|
</transition>
|
|
2802
|
+
|
|
2803
|
+
<!-- wiring:c2 — shortcut settings modal + Space quick-look overlay -->
|
|
2804
|
+
<ShortcutSettings
|
|
2805
|
+
:open="showShortcutSettings"
|
|
2806
|
+
:locale="locale"
|
|
2807
|
+
:theme="config.theme || 'auto'"
|
|
2808
|
+
@close="showShortcutSettings = false"
|
|
2809
|
+
/>
|
|
2810
|
+
<QuickLook
|
|
2811
|
+
:open="quickLookOpen"
|
|
2812
|
+
:locale="locale"
|
|
2813
|
+
:file="quickLookTarget"
|
|
2814
|
+
:theme="config.theme || 'auto'"
|
|
2815
|
+
:preview-url="(p: string) => api.previewUrl(p)"
|
|
2816
|
+
:download-url="(p: string) => api.downloadUrl(p)"
|
|
2817
|
+
:only-office-base="effectiveOnlyOfficeBase"
|
|
2818
|
+
:only-office-config-endpoint="effectiveOnlyOfficeConfigEndpoint"
|
|
2819
|
+
:auth-headers="() => buildAuthHeaders({ 'Content-Type': 'application/json' })"
|
|
2820
|
+
:auth-credentials="api.credentialsMode()"
|
|
2821
|
+
:drawio-url="effectiveDrawioUrl"
|
|
2822
|
+
:pdf-worker-url="props.config.pdfWorkerUrl || null"
|
|
2823
|
+
:viewer-base-url="props.config.viewerBaseUrl || null"
|
|
2824
|
+
@close="quickLookOpen = false"
|
|
2825
|
+
@nav="quickLookNav"
|
|
2826
|
+
@open-full="quickLookOpenFull"
|
|
2827
|
+
/>
|
|
2828
|
+
<!-- /wiring:c2 -->
|
|
2829
|
+
<!-- wiring:c4 — onboarding coach-mark tour (teleports itself to body) -->
|
|
2830
|
+
<OnboardingTour
|
|
2831
|
+
:open="showTour"
|
|
2832
|
+
:locale="locale"
|
|
2833
|
+
:root="rootEl"
|
|
2834
|
+
:theme="config.theme || 'auto'"
|
|
2835
|
+
@close="onTourClose"
|
|
2836
|
+
/>
|
|
2837
|
+
<!-- /wiring:c4 -->
|
|
2570
2838
|
</div>
|
|
2571
2839
|
</template>
|
|
2572
2840
|
|
|
@@ -53,6 +53,10 @@ const emit = defineEmits<{
|
|
|
53
53
|
(e: 'go-up'): void;
|
|
54
54
|
/* bul:s3 — an "everywhere" hit was chosen. */
|
|
55
55
|
(e: 'open-hit', hit: GlobalSearchHit): void;
|
|
56
|
+
/* wiring:int — settings surfaces reachable from anywhere via the palette */
|
|
57
|
+
(e: 'open-theme'): void;
|
|
58
|
+
(e: 'open-shortcut-settings'): void;
|
|
59
|
+
(e: 'start-tour'): void;
|
|
56
60
|
}>();
|
|
57
61
|
|
|
58
62
|
const { t, nodeDisplayName } = useLocale(() => props.locale);
|
|
@@ -126,6 +130,10 @@ const commandItems = computed<PaletteItem[]>(() => {
|
|
|
126
130
|
{ command: 'open-trash', label: t('cmd.trash'), icon: '🗑', enabled: true },
|
|
127
131
|
{ command: 'refresh', label: t('toolbar.refresh'), icon: '⟳', enabled: true },
|
|
128
132
|
{ command: 'go-up', label: t('toolbar.go_up'), icon: '↑', enabled: props.canGoUp !== false },
|
|
133
|
+
/* wiring:int */
|
|
134
|
+
{ command: 'open-theme', label: t('theme.menu'), icon: '🎨', enabled: true },
|
|
135
|
+
{ command: 'open-shortcut-settings', label: t('shortcuts.settings.menu'), icon: '⌨', enabled: true },
|
|
136
|
+
{ command: 'start-tour', label: t('tour.restart'), icon: '🎓', enabled: true },
|
|
129
137
|
];
|
|
130
138
|
return defs
|
|
131
139
|
.filter((d) => d.enabled && matchScore(d.label, q) >= 0)
|
|
@@ -310,6 +318,10 @@ function choose(item?: PaletteItem) {
|
|
|
310
318
|
case 'open-trash': emit('open-trash'); break;
|
|
311
319
|
case 'refresh': emit('refresh'); break;
|
|
312
320
|
case 'go-up': emit('go-up'); break;
|
|
321
|
+
/* wiring:int */
|
|
322
|
+
case 'open-theme': emit('open-theme'); break;
|
|
323
|
+
case 'open-shortcut-settings': emit('open-shortcut-settings'); break;
|
|
324
|
+
case 'start-tour': emit('start-tour'); break;
|
|
313
325
|
}
|
|
314
326
|
}
|
|
315
327
|
}
|
|
@@ -54,10 +54,23 @@ const y = ref(0);
|
|
|
54
54
|
const targetNodes = ref<FileNode[]>([]);
|
|
55
55
|
const menuEl = ref<HTMLElement | null>(null);
|
|
56
56
|
|
|
57
|
+
/* wiring:c4 — where the pointer opened the menu; kept separate from x/y so
|
|
58
|
+
* the edge-flip can re-anchor ABOVE/LEFT of the cursor instead of merely
|
|
59
|
+
* clamping (a clamp near the bottom edge used to leave the menu covering
|
|
60
|
+
* the clicked row). */
|
|
61
|
+
let anchorX = 0;
|
|
62
|
+
let anchorY = 0;
|
|
63
|
+
/* wiring:c4 — focus restore: whatever had focus before the menu opened gets
|
|
64
|
+
* it back on hide, so keyboard users don't fall back to <body>. */
|
|
65
|
+
let prevFocus: HTMLElement | null = null;
|
|
66
|
+
|
|
57
67
|
async function show(ev: { clientX: number; clientY: number }, nodes: FileNode[]) {
|
|
68
|
+
prevFocus = (document.activeElement as HTMLElement | null) ?? null; /* wiring:c4 */
|
|
58
69
|
open.value = true;
|
|
59
70
|
x.value = ev.clientX;
|
|
60
71
|
y.value = ev.clientY;
|
|
72
|
+
anchorX = ev.clientX; /* wiring:c4 */
|
|
73
|
+
anchorY = ev.clientY; /* wiring:c4 */
|
|
61
74
|
targetNodes.value = nodes;
|
|
62
75
|
// After the menu renders, clamp it inside the viewport so a click near the
|
|
63
76
|
// bottom/right edge doesn't push the menu off-screen (it opens down-right
|
|
@@ -70,6 +83,7 @@ async function show(ev: { clientX: number; clientY: number }, nodes: FileNode[])
|
|
|
70
83
|
return;
|
|
71
84
|
}
|
|
72
85
|
clampToViewport();
|
|
86
|
+
focusItem(0); /* wiring:c4 — keyboard nav starts on the first item */
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
function clampToViewport() {
|
|
@@ -79,11 +93,16 @@ function clampToViewport() {
|
|
|
79
93
|
const margin = 8;
|
|
80
94
|
const vw = window.innerWidth;
|
|
81
95
|
const vh = window.innerHeight;
|
|
82
|
-
|
|
83
|
-
|
|
96
|
+
/* wiring:c4 — edge flip: overflowing the bottom/right edge re-anchors the
|
|
97
|
+
* menu above/left of the cursor when there's room; clamping stays as the
|
|
98
|
+
* fallback for tiny viewports. */
|
|
99
|
+
if (anchorY + rect.height > vh - margin) {
|
|
100
|
+
const flipped = anchorY - rect.height;
|
|
101
|
+
y.value = flipped >= margin ? flipped : Math.max(margin, vh - rect.height - margin);
|
|
84
102
|
}
|
|
85
|
-
if (
|
|
86
|
-
|
|
103
|
+
if (anchorX + rect.width > vw - margin) {
|
|
104
|
+
const flipped = anchorX - rect.width;
|
|
105
|
+
x.value = flipped >= margin ? flipped : Math.max(margin, vw - rect.width - margin);
|
|
87
106
|
}
|
|
88
107
|
}
|
|
89
108
|
|
|
@@ -92,7 +111,42 @@ function hide() {
|
|
|
92
111
|
/* bag:b4 — reset any in-flight sheet drag so reopening starts clean. */
|
|
93
112
|
sheetDragging.value = false;
|
|
94
113
|
sheetDragY.value = 0;
|
|
114
|
+
/* wiring:c4 — give focus back to the opener (row/card/⋯ button). */
|
|
115
|
+
prevFocus?.focus?.();
|
|
116
|
+
prevFocus = null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* === wiring:c4 — keyboard navigation (menu + sheet) ===
|
|
120
|
+
* Arrow keys cycle through enabled items, Home/End jump, Enter/Space
|
|
121
|
+
* activate the focused <button> natively, Esc closes (see onKey). Disabled
|
|
122
|
+
* buttons are unfocusable, so the query skips them by construction. */
|
|
123
|
+
function menuItems(): HTMLElement[] {
|
|
124
|
+
const host = props.sheet ? sheetEl.value : menuEl.value;
|
|
125
|
+
if (!host) return [];
|
|
126
|
+
return Array.from(
|
|
127
|
+
host.querySelectorAll<HTMLElement>('.fe-ctx__item:not(:disabled)'),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function focusItem(idx: number) {
|
|
132
|
+
const items = menuItems();
|
|
133
|
+
if (items.length === 0) return;
|
|
134
|
+
const i = ((idx % items.length) + items.length) % items.length;
|
|
135
|
+
items[i]?.focus();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function moveFocus(delta: number) {
|
|
139
|
+
const items = menuItems();
|
|
140
|
+
if (items.length === 0) return;
|
|
141
|
+
const active = document.activeElement as HTMLElement | null;
|
|
142
|
+
const cur = active ? items.indexOf(active) : -1;
|
|
143
|
+
if (cur === -1) {
|
|
144
|
+
focusItem(delta > 0 ? 0 : items.length - 1);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
focusItem(cur + delta);
|
|
95
148
|
}
|
|
149
|
+
/* === /wiring:c4 === */
|
|
96
150
|
|
|
97
151
|
/* bag:b4 — sheet drag-to-dismiss. Bound to the grab handle only, so the
|
|
98
152
|
* scrollable item list keeps its native touch scrolling. Dragging past the
|
|
@@ -139,10 +193,60 @@ function pick(a: ContextAction) {
|
|
|
139
193
|
}
|
|
140
194
|
|
|
141
195
|
function onKey(e: KeyboardEvent) {
|
|
142
|
-
if (e.key === 'Escape')
|
|
196
|
+
if (e.key === 'Escape') {
|
|
197
|
+
// Scoped close: Esc with the menu open closes ONLY the menu — the
|
|
198
|
+
// explorer's global onClose (which also dismisses modals/toasts)
|
|
199
|
+
// shouldn't fire underneath.
|
|
200
|
+
e.stopPropagation();
|
|
201
|
+
hide();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
205
|
+
// Native button activation handles the pick; stop the bubble so the
|
|
206
|
+
// explorer's global Enter=open shortcut doesn't double-fire and pop
|
|
207
|
+
// the preview modal on top of the chosen action.
|
|
208
|
+
e.stopPropagation();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
/* wiring:c4 — arrow-key navigation; stopPropagation keeps the explorer's
|
|
212
|
+
* global shortcut handler (Enter=open, Backspace=up…) out of the loop
|
|
213
|
+
* while the menu is up. */
|
|
214
|
+
if (e.key === 'ArrowDown') {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
e.stopPropagation();
|
|
217
|
+
moveFocus(1);
|
|
218
|
+
} else if (e.key === 'ArrowUp') {
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
e.stopPropagation();
|
|
221
|
+
moveFocus(-1);
|
|
222
|
+
} else if (e.key === 'Home') {
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
focusItem(0);
|
|
225
|
+
} else if (e.key === 'End') {
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
focusItem(-1);
|
|
228
|
+
} else if (e.key === 'Tab') {
|
|
229
|
+
// A menu is not a tab-stop container — Tab closes it (WAI-ARIA menu
|
|
230
|
+
// pattern) instead of tabbing behind the backdrop.
|
|
231
|
+
e.preventDefault();
|
|
232
|
+
hide();
|
|
233
|
+
}
|
|
143
234
|
}
|
|
144
235
|
|
|
145
|
-
|
|
236
|
+
/* wiring:c4 — hidden-entry filtering used to leave orphaned separators
|
|
237
|
+
* (leading/trailing/doubled) when the entries around a divider were hidden
|
|
238
|
+
* by RBAC or selection shape. Collapse them here so every visible divider
|
|
239
|
+
* actually separates two groups. */
|
|
240
|
+
const visibleActions = computed(() => {
|
|
241
|
+
const vis = props.actions.filter((a) => !a.hidden);
|
|
242
|
+
const out: ContextAction[] = [];
|
|
243
|
+
for (const a of vis) {
|
|
244
|
+
if (a.divider && (out.length === 0 || out[out.length - 1].divider)) continue;
|
|
245
|
+
out.push(a);
|
|
246
|
+
}
|
|
247
|
+
while (out.length > 0 && out[out.length - 1].divider) out.pop();
|
|
248
|
+
return out;
|
|
249
|
+
});
|
|
146
250
|
|
|
147
251
|
const prefersDark = ref(false);
|
|
148
252
|
let mq: MediaQueryList | undefined;
|
|
@@ -204,7 +308,7 @@ defineExpose({ show, hide });
|
|
|
204
308
|
/>
|
|
205
309
|
<div class="fe-sheet__items">
|
|
206
310
|
<template v-for="(a, i) in visibleActions" :key="a.key || i">
|
|
207
|
-
<div v-if="a.divider" class="fe-ctx__sep" />
|
|
311
|
+
<div v-if="a.divider" class="fe-ctx__sep" role="separator" />
|
|
208
312
|
<button
|
|
209
313
|
v-else
|
|
210
314
|
type="button"
|
|
@@ -225,11 +329,13 @@ defineExpose({ show, hide });
|
|
|
225
329
|
ref="menuEl"
|
|
226
330
|
class="fe-ctx"
|
|
227
331
|
role="menu"
|
|
332
|
+
aria-orientation="vertical"
|
|
333
|
+
:aria-label="t('sheet.menu') /* wiring:c4 */"
|
|
228
334
|
:style="{ top: y + 'px', left: x + 'px' }"
|
|
229
335
|
@click.stop
|
|
230
336
|
>
|
|
231
337
|
<template v-for="(a, i) in visibleActions" :key="a.key || i">
|
|
232
|
-
<div v-if="a.divider" class="fe-ctx__sep" />
|
|
338
|
+
<div v-if="a.divider" class="fe-ctx__sep" role="separator" />
|
|
233
339
|
<button
|
|
234
340
|
v-else
|
|
235
341
|
type="button"
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* GridView — card grid. Thumbnails preferred, fall back to icon.
|
|
4
4
|
*/
|
|
5
|
+
import { ref } from 'vue'; /* wiring:c4 */
|
|
5
6
|
import type { FileNode } from '../types/FileNode';
|
|
6
7
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
7
8
|
import { useLocale } from '../composables/useLocale';
|
|
8
9
|
import { fileIconSvg } from '../lib/fileIcons';
|
|
9
10
|
import { snippetSegments } from '../lib/snippet'; /* bul:s3 */
|
|
11
|
+
import { applyDragGhost } from '../lib/dragGhost'; /* wiring:c4 */
|
|
10
12
|
|
|
11
13
|
const props = defineProps<{
|
|
12
14
|
files: FileNode[];
|
|
@@ -56,18 +58,34 @@ function onCtx(n: FileNode, ev: MouseEvent) {
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
function onItemDragStart(n: FileNode, ev: DragEvent) {
|
|
61
|
+
/* wiring:c4 — custom ghost (name + multi-select count badge), visual only. */
|
|
62
|
+
applyDragGhost(
|
|
63
|
+
ev,
|
|
64
|
+
nodeDisplayName(n),
|
|
65
|
+
props.selected.has(n.path) ? props.selected.size : 1,
|
|
66
|
+
);
|
|
59
67
|
emit('item-drag-start', n, ev);
|
|
60
68
|
}
|
|
61
69
|
|
|
70
|
+
/* wiring:c4 — droptarget highlight, mirrors ListView (visual layer only). */
|
|
71
|
+
const dropTargetPath = ref<string | null>(null);
|
|
72
|
+
|
|
62
73
|
function onItemDragOver(n: FileNode, ev: DragEvent) {
|
|
63
74
|
if (n.type !== 'dir') return;
|
|
64
75
|
if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
|
|
65
76
|
ev.preventDefault();
|
|
66
77
|
ev.stopPropagation();
|
|
67
78
|
if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
|
|
79
|
+
dropTargetPath.value = n.path; /* wiring:c4 */
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* wiring:c4 */
|
|
83
|
+
function onItemDragLeave(n: FileNode) {
|
|
84
|
+
if (dropTargetPath.value === n.path) dropTargetPath.value = null;
|
|
68
85
|
}
|
|
69
86
|
|
|
70
87
|
function onItemDrop(n: FileNode, ev: DragEvent) {
|
|
88
|
+
dropTargetPath.value = null; /* wiring:c4 */
|
|
71
89
|
if (n.type !== 'dir') return;
|
|
72
90
|
if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
|
|
73
91
|
ev.preventDefault();
|
|
@@ -128,7 +146,16 @@ function snippetTitle(snippet: string): string {
|
|
|
128
146
|
</script>
|
|
129
147
|
|
|
130
148
|
<template>
|
|
131
|
-
|
|
149
|
+
<!-- wiring:c4 — listbox semantics (multi-selectable cards as options);
|
|
150
|
+
localized label + busy state. Structure/layout untouched. -->
|
|
151
|
+
<div
|
|
152
|
+
class="fe-grid"
|
|
153
|
+
:class="{ 'is-loading': loading }"
|
|
154
|
+
role="listbox"
|
|
155
|
+
aria-multiselectable="true"
|
|
156
|
+
:aria-label="t('grid.aria')"
|
|
157
|
+
:aria-busy="loading ? 'true' : undefined"
|
|
158
|
+
>
|
|
132
159
|
<div
|
|
133
160
|
v-for="n in files"
|
|
134
161
|
:key="n.path"
|
|
@@ -138,14 +165,19 @@ function snippetTitle(snippet: string): string {
|
|
|
138
165
|
'is-dir': n.type === 'dir',
|
|
139
166
|
'is-trash': n.trashed,
|
|
140
167
|
'is-clipped': clipped?.has(n.path),
|
|
168
|
+
'is-droptarget': dropTargetPath === n.path /* wiring:c4 */,
|
|
141
169
|
}"
|
|
142
170
|
tabindex="0"
|
|
171
|
+
role="option"
|
|
172
|
+
:aria-selected="isSelected(n) ? 'true' : 'false'"
|
|
173
|
+
:aria-label="nodeDisplayName(n) /* wiring:c4 */"
|
|
143
174
|
draggable="true"
|
|
144
175
|
@click="onClick(n, $event)"
|
|
145
176
|
@dblclick="onDbl(n)"
|
|
146
177
|
@contextmenu="onCtx(n, $event)"
|
|
147
178
|
@dragstart="onItemDragStart(n, $event)"
|
|
148
179
|
@dragover="onItemDragOver(n, $event)"
|
|
180
|
+
@dragleave="onItemDragLeave(n) /* wiring:c4 */"
|
|
149
181
|
@drop="onItemDrop(n, $event)"
|
|
150
182
|
@touchstart.passive="onTouchStart(n, $event)"
|
|
151
183
|
@touchend="cancelPress"
|