@brftech/filex-core 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ArchiveViewer-B_0IQExT.js → ArchiveViewer-EGgNqLPE.js} +2 -2
- package/dist/{ArchiveViewer-B_0IQExT.js.map → ArchiveViewer-EGgNqLPE.js.map} +1 -1
- package/dist/{CsvViewer-maMfUy5i.js → CsvViewer-BvriJ42M.js} +2 -2
- package/dist/{CsvViewer-maMfUy5i.js.map → CsvViewer-BvriJ42M.js.map} +1 -1
- package/dist/{DrawioViewer-DP_pXI00.js → DrawioViewer-B_bSPsWX.js} +2 -2
- package/dist/{DrawioViewer-DP_pXI00.js.map → DrawioViewer-B_bSPsWX.js.map} +1 -1
- package/dist/{EpubViewer-DSaqGnVx.js → EpubViewer-hl68h4gG.js} +2 -2
- package/dist/{EpubViewer-DSaqGnVx.js.map → EpubViewer-hl68h4gG.js.map} +1 -1
- package/dist/{IpynbViewer-BmdBanF1.js → IpynbViewer-lhwOjXK7.js} +2 -2
- package/dist/{IpynbViewer-BmdBanF1.js.map → IpynbViewer-lhwOjXK7.js.map} +1 -1
- package/dist/{MermaidViewer-Drlkl0Z5.js → MermaidViewer-H2Z6AYXg.js} +2 -2
- package/dist/{MermaidViewer-Drlkl0Z5.js.map → MermaidViewer-H2Z6AYXg.js.map} +1 -1
- package/dist/{PsdViewer-gftUJrsm.js → PsdViewer-Bsk-11yH.js} +2 -2
- package/dist/{PsdViewer-gftUJrsm.js.map → PsdViewer-Bsk-11yH.js.map} +1 -1
- package/dist/{TiffViewer-0Uv0-Lxh.js → TiffViewer-D-j9vycG.js} +2 -2
- package/dist/{TiffViewer-0Uv0-Lxh.js.map → TiffViewer-D-j9vycG.js.map} +1 -1
- package/dist/{Viewer3D-Gu3kUpxV.js → Viewer3D-Bcr70rTe.js} +2 -2
- package/dist/{Viewer3D-Gu3kUpxV.js.map → Viewer3D-Bcr70rTe.js.map} +1 -1
- package/dist/filex-core.js +50 -25
- package/dist/filex-core.umd.cjs +59 -58
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-C_ZSnk_I.js +11109 -0
- package/dist/index-C_ZSnk_I.js.map +1 -0
- package/dist/index.d.ts +597 -4
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +636 -15
- package/src/components/CommandPalette.vue +24 -1
- package/src/components/ContextMenu.vue +114 -8
- package/src/components/GalleryView.vue +214 -0
- package/src/components/GridView.vue +34 -1
- package/src/components/InspectorPanel.vue +170 -0
- package/src/components/ListView.vue +40 -6
- package/src/components/OnboardingTour.vue +332 -0
- package/src/components/OperationsCenter.vue +284 -0
- package/src/components/PendingOpsTray.vue +50 -66
- package/src/components/QuickLook.vue +142 -0
- package/src/components/SecondaryPane.vue +411 -0
- package/src/components/ShortcutSettings.vue +270 -0
- package/src/components/ShortcutsHelp.vue +30 -9
- package/src/components/TabBar.vue +174 -0
- package/src/components/ThemeGallery.vue +253 -0
- package/src/components/Toolbar.vue +75 -11
- package/src/components/UploadProgress.vue +49 -57
- package/src/composables/useFileApi.ts +53 -0
- package/src/composables/useKeyboardShortcuts.ts +340 -137
- package/src/composables/useOperations.ts +291 -0
- package/src/composables/useTabs.ts +222 -0
- package/src/index.ts +51 -0
- package/src/lib/dragGhost.ts +41 -0
- package/src/lib/themes.ts +505 -0
- package/src/locales/en.ts +125 -0
- package/src/locales/tr.ts +125 -0
- package/src/modals/Modal.vue +41 -5
- package/src/styles/base.css +1210 -0
- package/src/types/FileNode.ts +1 -1
- package/dist/index-BLocdbmc.js +0 -7993
- package/dist/index-BLocdbmc.js.map +0 -1
|
@@ -39,6 +39,8 @@ const props = defineProps<{
|
|
|
39
39
|
canGoUp?: boolean;
|
|
40
40
|
/* bul:s3 — global search callback; absent = older host, group hidden. */
|
|
41
41
|
globalSearch?: (q: string) => Promise<GlobalSearchHit[]>;
|
|
42
|
+
/* wiring:d1 — false hides the split command (narrow embeds). */
|
|
43
|
+
splitEnabled?: boolean;
|
|
42
44
|
}>();
|
|
43
45
|
|
|
44
46
|
const emit = defineEmits<{
|
|
@@ -53,6 +55,13 @@ const emit = defineEmits<{
|
|
|
53
55
|
(e: 'go-up'): void;
|
|
54
56
|
/* bul:s3 — an "everywhere" hit was chosen. */
|
|
55
57
|
(e: 'open-hit', hit: GlobalSearchHit): void;
|
|
58
|
+
/* wiring:int — settings surfaces reachable from anywhere via the palette */
|
|
59
|
+
(e: 'open-theme'): void;
|
|
60
|
+
(e: 'open-shortcut-settings'): void;
|
|
61
|
+
(e: 'start-tour'): void;
|
|
62
|
+
/* wiring:d1 — tab / split commands */
|
|
63
|
+
(e: 'tab-new'): void;
|
|
64
|
+
(e: 'split-toggle'): void;
|
|
56
65
|
}>();
|
|
57
66
|
|
|
58
67
|
const { t, nodeDisplayName } = useLocale(() => props.locale);
|
|
@@ -122,10 +131,17 @@ const commandItems = computed<PaletteItem[]>(() => {
|
|
|
122
131
|
const defs: Array<{ command: string; label: string; icon: string; enabled: boolean }> = [
|
|
123
132
|
{ command: 'new-folder', label: t('toolbar.new_folder'), icon: '📁', enabled: props.canWrite !== false },
|
|
124
133
|
{ command: 'upload', label: t('toolbar.upload'), icon: '⬆', enabled: props.canWrite !== false },
|
|
125
|
-
{ command: 'toggle-view', label: t('cmd.view_toggle'), icon: props.viewMode === 'list' ? '▦' : '☰', enabled: true },
|
|
134
|
+
{ command: 'toggle-view', label: t('cmd.view_toggle'), icon: props.viewMode === 'list' ? '▦' : props.viewMode === 'grid' ? '▣' : '☰', enabled: true /* wiring:d2 — sıradaki mod ikonu (list→grid→galeri→list) */ },
|
|
126
135
|
{ command: 'open-trash', label: t('cmd.trash'), icon: '🗑', enabled: true },
|
|
127
136
|
{ command: 'refresh', label: t('toolbar.refresh'), icon: '⟳', enabled: true },
|
|
128
137
|
{ command: 'go-up', label: t('toolbar.go_up'), icon: '↑', enabled: props.canGoUp !== false },
|
|
138
|
+
/* wiring:int */
|
|
139
|
+
{ command: 'open-theme', label: t('theme.menu'), icon: '🎨', enabled: true },
|
|
140
|
+
{ command: 'open-shortcut-settings', label: t('shortcuts.settings.menu'), icon: '⌨', enabled: true },
|
|
141
|
+
{ command: 'start-tour', label: t('tour.restart'), icon: '🎓', enabled: true },
|
|
142
|
+
/* wiring:d1 — sekme + split */
|
|
143
|
+
{ command: 'tab-new', label: t('cmd.tab_new'), icon: '⧉', enabled: true },
|
|
144
|
+
{ command: 'split-toggle', label: t('cmd.split_toggle'), icon: '◫', enabled: props.splitEnabled !== false },
|
|
129
145
|
];
|
|
130
146
|
return defs
|
|
131
147
|
.filter((d) => d.enabled && matchScore(d.label, q) >= 0)
|
|
@@ -310,6 +326,13 @@ function choose(item?: PaletteItem) {
|
|
|
310
326
|
case 'open-trash': emit('open-trash'); break;
|
|
311
327
|
case 'refresh': emit('refresh'); break;
|
|
312
328
|
case 'go-up': emit('go-up'); break;
|
|
329
|
+
/* wiring:int */
|
|
330
|
+
case 'open-theme': emit('open-theme'); break;
|
|
331
|
+
case 'open-shortcut-settings': emit('open-shortcut-settings'); break;
|
|
332
|
+
case 'start-tour': emit('start-tour'); break;
|
|
333
|
+
/* wiring:d1 */
|
|
334
|
+
case 'tab-new': emit('tab-new'); break;
|
|
335
|
+
case 'split-toggle': emit('split-toggle'); break;
|
|
313
336
|
}
|
|
314
337
|
}
|
|
315
338
|
}
|
|
@@ -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"
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// (unscoped styles by design — see base.css wiring:d2 block; SFC scoped
|
|
3
|
+
// styles are banned for webcomponent data-v hash mismatch reasons.)
|
|
4
|
+
/**
|
|
5
|
+
* GalleryView — wiring:d2. Third view mode: large-thumbnail gallery for
|
|
6
|
+
* visual browsing (photos/videos). Derived from GridView (same props/emits,
|
|
7
|
+
* same listbox a11y, same selection/drag/touch contract) but renders big
|
|
8
|
+
* square tiles: thumbnail (or SVG file-type icon fallback) with the name
|
|
9
|
+
* below and size+date revealed on hover/focus. GridView itself is untouched.
|
|
10
|
+
*/
|
|
11
|
+
import { ref } from 'vue';
|
|
12
|
+
import type { FileNode } from '../types/FileNode';
|
|
13
|
+
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
14
|
+
import { useLocale } from '../composables/useLocale';
|
|
15
|
+
import { fileIconSvg } from '../lib/fileIcons';
|
|
16
|
+
import { applyDragGhost } from '../lib/dragGhost';
|
|
17
|
+
|
|
18
|
+
const props = defineProps<{
|
|
19
|
+
files: FileNode[];
|
|
20
|
+
selected: Set<string>;
|
|
21
|
+
clipped?: Set<string>;
|
|
22
|
+
showParentPath?: boolean;
|
|
23
|
+
locale: LocaleCode;
|
|
24
|
+
loading?: boolean;
|
|
25
|
+
/** Authenticated thumb resolver (useThumbs.src) — same contract as
|
|
26
|
+
* GridView: raw `thumb_url` is root-relative and unauthenticated, so
|
|
27
|
+
* embedded hosts NEED this. null = icon fallback. */
|
|
28
|
+
thumbSrc?: (n: FileNode) => string | null;
|
|
29
|
+
}>();
|
|
30
|
+
|
|
31
|
+
const emit = defineEmits<{
|
|
32
|
+
(e: 'click-card', node: FileNode, mod: { ctrl: boolean; shift: boolean }): void;
|
|
33
|
+
(e: 'dbl-card', node: FileNode): void;
|
|
34
|
+
(e: 'context-card', node: FileNode, ev: MouseEvent): void;
|
|
35
|
+
(e: 'item-drag-start', node: FileNode, ev: DragEvent): void;
|
|
36
|
+
(e: 'item-drop-into', target: FileNode, ev: DragEvent): void;
|
|
37
|
+
}>();
|
|
38
|
+
|
|
39
|
+
const { t, formatSize, nodeDisplayName } = useLocale(() => props.locale);
|
|
40
|
+
|
|
41
|
+
function thumbOf(n: FileNode): string | null {
|
|
42
|
+
return props.thumbSrc ? props.thumbSrc(n) : (n.thumb_url ?? null);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isSelected(n: FileNode): boolean {
|
|
46
|
+
return props.selected.has(n.path);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function onClick(n: FileNode, ev: MouseEvent) {
|
|
50
|
+
emit('click-card', n, { ctrl: ev.ctrlKey || ev.metaKey, shift: ev.shiftKey });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onDbl(n: FileNode) {
|
|
54
|
+
emit('dbl-card', n);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function onCtx(n: FileNode, ev: MouseEvent) {
|
|
58
|
+
ev.preventDefault();
|
|
59
|
+
ev.stopPropagation();
|
|
60
|
+
emit('context-card', n, ev);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function onItemDragStart(n: FileNode, ev: DragEvent) {
|
|
64
|
+
applyDragGhost(
|
|
65
|
+
ev,
|
|
66
|
+
nodeDisplayName(n),
|
|
67
|
+
props.selected.has(n.path) ? props.selected.size : 1,
|
|
68
|
+
);
|
|
69
|
+
emit('item-drag-start', n, ev);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Drop-target highlight — mirrors GridView (visual layer only).
|
|
73
|
+
const dropTargetPath = ref<string | null>(null);
|
|
74
|
+
|
|
75
|
+
function onItemDragOver(n: FileNode, ev: DragEvent) {
|
|
76
|
+
if (n.type !== 'dir') return;
|
|
77
|
+
if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
|
|
78
|
+
ev.preventDefault();
|
|
79
|
+
ev.stopPropagation();
|
|
80
|
+
if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
|
|
81
|
+
dropTargetPath.value = n.path;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function onItemDragLeave(n: FileNode) {
|
|
85
|
+
if (dropTargetPath.value === n.path) dropTargetPath.value = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function onItemDrop(n: FileNode, ev: DragEvent) {
|
|
89
|
+
dropTargetPath.value = null;
|
|
90
|
+
if (n.type !== 'dir') return;
|
|
91
|
+
if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
|
|
92
|
+
ev.preventDefault();
|
|
93
|
+
ev.stopPropagation();
|
|
94
|
+
emit('item-drop-into', n, ev);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Long-press → context menu, same as GridView (touch parity).
|
|
98
|
+
let pressTimer: ReturnType<typeof setTimeout> | undefined;
|
|
99
|
+
let pressTarget: FileNode | null = null;
|
|
100
|
+
function onTouchStart(n: FileNode, ev: TouchEvent) {
|
|
101
|
+
pressTarget = n;
|
|
102
|
+
if (pressTimer) clearTimeout(pressTimer);
|
|
103
|
+
pressTimer = setTimeout(() => {
|
|
104
|
+
if (pressTarget) {
|
|
105
|
+
const t0 = ev.touches[0];
|
|
106
|
+
emit('context-card', pressTarget, {
|
|
107
|
+
clientX: t0.clientX,
|
|
108
|
+
clientY: t0.clientY,
|
|
109
|
+
preventDefault: () => {},
|
|
110
|
+
} as unknown as MouseEvent);
|
|
111
|
+
}
|
|
112
|
+
}, 500);
|
|
113
|
+
}
|
|
114
|
+
function cancelPress() {
|
|
115
|
+
if (pressTimer) clearTimeout(pressTimer);
|
|
116
|
+
pressTarget = null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parentDir(path: string): string {
|
|
120
|
+
const stripped = path.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
|
|
121
|
+
const idx = stripped.lastIndexOf('/');
|
|
122
|
+
if (idx === -1) return '';
|
|
123
|
+
return stripped.slice(0, idx);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Special rows keep their emoji (trash/storage are not file-TYPE icons).
|
|
127
|
+
function specialEmojiFor(n: FileNode): string | null {
|
|
128
|
+
if (n.basename === '.trash') return '🗑';
|
|
129
|
+
if (n.mime_type === 'inode/storage') return '💾';
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function displayDate(ms: number | undefined): string {
|
|
134
|
+
if (!ms) return '';
|
|
135
|
+
const d = new Date(ms * (ms < 1e12 ? 1000 : 1));
|
|
136
|
+
return d.toLocaleString();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Hover meta line: size for files, entry count (when known) for dirs.
|
|
140
|
+
function metaFor(n: FileNode): string {
|
|
141
|
+
const date = displayDate(n.last_modified);
|
|
142
|
+
const size = n.type === 'dir' ? '' : formatSize(n.size);
|
|
143
|
+
return [size, date].filter((s) => !!s).join(' · ');
|
|
144
|
+
}
|
|
145
|
+
</script>
|
|
146
|
+
|
|
147
|
+
<template>
|
|
148
|
+
<div
|
|
149
|
+
class="fe-gal"
|
|
150
|
+
:class="{ 'is-loading': loading }"
|
|
151
|
+
role="listbox"
|
|
152
|
+
aria-multiselectable="true"
|
|
153
|
+
:aria-label="t('gallery.aria')"
|
|
154
|
+
:aria-busy="loading ? 'true' : undefined"
|
|
155
|
+
>
|
|
156
|
+
<div
|
|
157
|
+
v-for="n in files"
|
|
158
|
+
:key="n.path"
|
|
159
|
+
class="fe-gal__card"
|
|
160
|
+
:class="{
|
|
161
|
+
'is-selected': isSelected(n),
|
|
162
|
+
'is-dir': n.type === 'dir',
|
|
163
|
+
'is-trash': n.trashed,
|
|
164
|
+
'is-clipped': clipped?.has(n.path),
|
|
165
|
+
'is-droptarget': dropTargetPath === n.path,
|
|
166
|
+
}"
|
|
167
|
+
tabindex="0"
|
|
168
|
+
role="option"
|
|
169
|
+
:aria-selected="isSelected(n) ? 'true' : 'false'"
|
|
170
|
+
:aria-label="nodeDisplayName(n)"
|
|
171
|
+
draggable="true"
|
|
172
|
+
@click="onClick(n, $event)"
|
|
173
|
+
@dblclick="onDbl(n)"
|
|
174
|
+
@contextmenu="onCtx(n, $event)"
|
|
175
|
+
@dragstart="onItemDragStart(n, $event)"
|
|
176
|
+
@dragover="onItemDragOver(n, $event)"
|
|
177
|
+
@dragleave="onItemDragLeave(n)"
|
|
178
|
+
@drop="onItemDrop(n, $event)"
|
|
179
|
+
@touchstart.passive="onTouchStart(n, $event)"
|
|
180
|
+
@touchend="cancelPress"
|
|
181
|
+
@touchmove="cancelPress"
|
|
182
|
+
>
|
|
183
|
+
<div class="fe-gal__thumb">
|
|
184
|
+
<!-- draggable="false" for the same reason as GridView: HTML5 image
|
|
185
|
+
drag adds a 'Files' MIME to dataTransfer, which would trip the
|
|
186
|
+
parent's upload handler on internal drags. -->
|
|
187
|
+
<img
|
|
188
|
+
v-if="thumbOf(n)"
|
|
189
|
+
:src="thumbOf(n)!"
|
|
190
|
+
:alt="n.basename"
|
|
191
|
+
loading="lazy"
|
|
192
|
+
draggable="false"
|
|
193
|
+
/>
|
|
194
|
+
<span v-else-if="specialEmojiFor(n)" class="fe-gal__icon">{{ specialEmojiFor(n) }}</span>
|
|
195
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
|
|
196
|
+
<span v-else class="fe-gal__icon fe-gal__icon--svg" v-html="fileIconSvg(n)"></span>
|
|
197
|
+
<div class="fe-gal__meta" aria-hidden="true">
|
|
198
|
+
<span v-if="metaFor(n)" class="fe-gal__meta-line">{{ metaFor(n) }}</span>
|
|
199
|
+
<span
|
|
200
|
+
v-if="showParentPath && parentDir(n.path)"
|
|
201
|
+
class="fe-gal__meta-line fe-gal__meta-line--path"
|
|
202
|
+
:title="parentDir(n.path)"
|
|
203
|
+
>{{ parentDir(n.path) }}</span>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
<div class="fe-gal__label" :title="n.basename">
|
|
207
|
+
{{ nodeDisplayName(n) }}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
<div v-if="!loading && files.length === 0" class="fe-gal__empty">
|
|
211
|
+
{{ t('empty.folder') }}
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</template>
|
|
@@ -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,20 @@ 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 */"
|
|
174
|
+
:data-fe-path="n.path /* wiring:d1 — orta-tık yeni sekme delegasyonu */"
|
|
143
175
|
draggable="true"
|
|
144
176
|
@click="onClick(n, $event)"
|
|
145
177
|
@dblclick="onDbl(n)"
|
|
146
178
|
@contextmenu="onCtx(n, $event)"
|
|
147
179
|
@dragstart="onItemDragStart(n, $event)"
|
|
148
180
|
@dragover="onItemDragOver(n, $event)"
|
|
181
|
+
@dragleave="onItemDragLeave(n) /* wiring:c4 */"
|
|
149
182
|
@drop="onItemDrop(n, $event)"
|
|
150
183
|
@touchstart.passive="onTouchStart(n, $event)"
|
|
151
184
|
@touchend="cancelPress"
|