@brftech/filex-core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/{ArchiveViewer-DKW-HyXT.js → ArchiveViewer-w6GDJJt9.js} +2 -2
  2. package/dist/{ArchiveViewer-DKW-HyXT.js.map → ArchiveViewer-w6GDJJt9.js.map} +1 -1
  3. package/dist/{CsvViewer-D2HmP2yr.js → CsvViewer-Ci5-mke8.js} +2 -2
  4. package/dist/{CsvViewer-D2HmP2yr.js.map → CsvViewer-Ci5-mke8.js.map} +1 -1
  5. package/dist/{DrawioViewer-DPNvOFCs.js → DrawioViewer-D2iMuQfD.js} +2 -2
  6. package/dist/{DrawioViewer-DPNvOFCs.js.map → DrawioViewer-D2iMuQfD.js.map} +1 -1
  7. package/dist/{EpubViewer-ByqjNuNL.js → EpubViewer-D4ydsa9X.js} +2 -2
  8. package/dist/{EpubViewer-ByqjNuNL.js.map → EpubViewer-D4ydsa9X.js.map} +1 -1
  9. package/dist/{IpynbViewer-2iONhbuJ.js → IpynbViewer-CWBwiFKX.js} +2 -2
  10. package/dist/{IpynbViewer-2iONhbuJ.js.map → IpynbViewer-CWBwiFKX.js.map} +1 -1
  11. package/dist/{MermaidViewer-CmiU7hIT.js → MermaidViewer-CDhuqQli.js} +2 -2
  12. package/dist/{MermaidViewer-CmiU7hIT.js.map → MermaidViewer-CDhuqQli.js.map} +1 -1
  13. package/dist/{PsdViewer-Inhl2yN9.js → PsdViewer-C_TkFb2H.js} +2 -2
  14. package/dist/{PsdViewer-Inhl2yN9.js.map → PsdViewer-C_TkFb2H.js.map} +1 -1
  15. package/dist/{TiffViewer-C2AFFEze.js → TiffViewer-BmWCXR7e.js} +2 -2
  16. package/dist/{TiffViewer-C2AFFEze.js.map → TiffViewer-BmWCXR7e.js.map} +1 -1
  17. package/dist/{Viewer3D-YdW14q_n.js → Viewer3D-5kPT1QsY.js} +2 -2
  18. package/dist/{Viewer3D-YdW14q_n.js.map → Viewer3D-5kPT1QsY.js.map} +1 -1
  19. package/dist/filex-core.js +66 -47
  20. package/dist/filex-core.umd.cjs +41 -40
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/index-nc0-oI_l.js +11723 -0
  23. package/dist/index-nc0-oI_l.js.map +1 -0
  24. package/dist/index.d.ts +364 -1
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +756 -23
  28. package/src/components/CommandPalette.vue +12 -1
  29. package/src/components/EncryptedFolderModal.vue +125 -0
  30. package/src/components/GalleryView.vue +215 -0
  31. package/src/components/GridView.vue +2 -0
  32. package/src/components/InspectorPanel.vue +170 -0
  33. package/src/components/ListView.vue +2 -0
  34. package/src/components/SecondaryPane.vue +411 -0
  35. package/src/components/TabBar.vue +174 -0
  36. package/src/components/Toolbar.vue +23 -5
  37. package/src/composables/useFileApi.ts +60 -0
  38. package/src/composables/useKeyboardShortcuts.ts +19 -0
  39. package/src/composables/useTabs.ts +222 -0
  40. package/src/index.ts +27 -0
  41. package/src/lib/e2ecrypto.ts +299 -0
  42. package/src/locales/en.ts +67 -0
  43. package/src/locales/tr.ts +67 -0
  44. package/src/modals/NewFolderModal.vue +18 -0
  45. package/src/modals/PreviewModal.vue +7 -2
  46. package/src/styles/base.css +631 -0
  47. package/src/types/FileNode.ts +4 -1
  48. package/dist/index-eARevpz0.js +0 -9952
  49. package/dist/index-eARevpz0.js.map +0 -1
@@ -0,0 +1,411 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * SecondaryPane — wiring:d1 split görünümün sağ (ikincil) paneli.
4
+ *
5
+ * Deliberately LIGHT: its own path + its own listing — fetched through
6
+ * the SAME FileApi instance the host already constructed (`api.index`,
7
+ * no copied fetch logic) — a small breadcrumb, double-click navigation,
8
+ * click selection and drag&drop in BOTH directions. No toolbar, no
9
+ * inspector, no upload: those belong to the main panel.
10
+ *
11
+ * Path semantics, confinement (`clamp`) and wire-form resolution
12
+ * (`qualify` / `toUser`) are injected from the host so the pane can
13
+ * never drift from the main panel's multi-storage / rootFloor rules.
14
+ *
15
+ * Cross-pane transfers only EMIT (`transfer`); the host decides
16
+ * move-vs-copy and runs the existing ops APIs. Unscoped styles
17
+ * (`fe-split*` in base.css) — webcomponent data-v rule.
18
+ */
19
+ import { computed, ref } from 'vue';
20
+ import type { FileApi } from '../composables/useFileApi';
21
+ import type { FileNode } from '../types/FileNode';
22
+ import type { LocaleCode } from '../types/ExplorerConfig';
23
+ import { useLocale } from '../composables/useLocale';
24
+ import { fileIconSvg } from '../lib/fileIcons';
25
+
26
+ // Same literals ListView/GridView already hardcode for the internal DnD
27
+ // channel; `-src` carries the origin directory so the host can move (and
28
+ // undo) correctly across panes.
29
+ const FE_DND_MIME = 'application/x-brf-files';
30
+ const FE_DND_SRC_MIME = 'application/x-brf-files-src';
31
+
32
+ const props = defineProps<{
33
+ api: FileApi;
34
+ /** Opening location, in the host's `currentPath` form. */
35
+ initialPath: string;
36
+ locale: LocaleCode;
37
+ /** Host's own qualify(): user path → wire `<adapter>://<rel>`. */
38
+ qualify: (p: string) => string;
39
+ /** Host's wire → user-path converter (multi-storage aware). */
40
+ toUser: (wire: string) => string;
41
+ /** Host's rootFloor clamp — keeps the pane inside the confine. */
42
+ clamp: (p: string) => string;
43
+ /** Label for the root crumb (adapter name / storage list). */
44
+ rootLabel: string;
45
+ /** rootFloor (user-path form, '' when unconfined) — trims crumbs. */
46
+ floor?: string;
47
+ /** Multi-storage mode: '' is the virtual drives root (no fetch). */
48
+ multiRoot?: boolean;
49
+ /** Synthesized storage rows for the virtual drives root. */
50
+ virtualRows?: () => FileNode[];
51
+ /** Active-panel highlight (keyboard target). */
52
+ active?: boolean;
53
+ }>();
54
+
55
+ const emit = defineEmits<{
56
+ (e: 'navigate', path: string): void;
57
+ (e: 'activate'): void;
58
+ (e: 'close'): void;
59
+ (e: 'open-tab', path: string): void;
60
+ (e: 'transfer', p: { sources: string[]; targetWire: string; originWire?: string }): void;
61
+ }>();
62
+
63
+ const { t, formatSize, nodeDisplayName } = useLocale(() => props.locale);
64
+
65
+ const path = ref<string>('');
66
+ const files = ref<FileNode[]>([]);
67
+ const loading = ref(false);
68
+ const error = ref('');
69
+ const selected = ref(new Set<string>());
70
+
71
+ function isStorageRow(n: FileNode): boolean {
72
+ return n.mime_type === 'inode/storage';
73
+ }
74
+
75
+ const atVirtualRoot = computed(
76
+ () => !!props.multiRoot && !(path.value ?? '').replace(/^\/+|\/+$/g, ''),
77
+ );
78
+
79
+ async function loadPane(target?: string): Promise<void> {
80
+ const requested = props.clamp(target ?? path.value ?? '');
81
+ loading.value = true;
82
+ error.value = '';
83
+ try {
84
+ // Multi-storage virtual root: synthesize the drives list, no backend
85
+ // call — mirrors the main panel's load() branch.
86
+ if (props.multiRoot && !requested) {
87
+ files.value = props.virtualRows ? props.virtualRows() : [];
88
+ path.value = '';
89
+ selected.value = new Set();
90
+ emit('navigate', '');
91
+ return;
92
+ }
93
+ const resp = await props.api.index(props.qualify(requested));
94
+ // Same internal-entry filter as the main listing.
95
+ files.value = (resp.files || []).filter((f) => {
96
+ if (f.path.includes('.thumbs')) return false;
97
+ if (f.path.includes('.versions') || f.basename === '.versions') return false;
98
+ if (f.basename === '.trash') return false;
99
+ if (f.basename === '.keepdir') return false;
100
+ return true;
101
+ });
102
+ path.value = props.toUser(resp.dirname);
103
+ selected.value = new Set();
104
+ emit('navigate', path.value);
105
+ } catch (err) {
106
+ error.value = err instanceof Error ? err.message : String(err);
107
+ } finally {
108
+ loading.value = false;
109
+ }
110
+ }
111
+
112
+ void loadPane(props.initialPath ?? '');
113
+
114
+ // ------------------------------------------------------------------
115
+ // Breadcrumb (kırıntı)
116
+ // ------------------------------------------------------------------
117
+
118
+ interface Crumb {
119
+ label: string;
120
+ target: string;
121
+ last: boolean;
122
+ }
123
+
124
+ const crumbs = computed<Crumb[]>(() => {
125
+ const clean = (path.value || '').replace(/^\/+|\/+$/g, '');
126
+ const segs = clean ? clean.split('/') : [];
127
+ const floor = (props.floor || '').replace(/^\/+|\/+$/g, '');
128
+ const out: Crumb[] = [];
129
+ let acc = '';
130
+ for (const s of segs) {
131
+ acc = acc ? `${acc}/${s}` : s;
132
+ // Confined embeds: hide the segments strictly ABOVE the floor — the
133
+ // floor segment itself acts as the pane's root crumb.
134
+ if (floor && !(acc === floor || acc.startsWith(floor + '/'))) continue;
135
+ out.push({ label: s, target: acc, last: false });
136
+ }
137
+ if (out.length > 0) out[out.length - 1].last = true;
138
+ return out;
139
+ });
140
+
141
+ const showRootCrumb = computed(() => !props.floor);
142
+
143
+ function crumbGo(target: string) {
144
+ emit('activate');
145
+ void loadPane(target);
146
+ }
147
+
148
+ // ------------------------------------------------------------------
149
+ // Selection + navigation
150
+ // ------------------------------------------------------------------
151
+
152
+ function onRowClick(n: FileNode, ev: MouseEvent) {
153
+ emit('activate');
154
+ const multi = ev.ctrlKey || ev.metaKey;
155
+ const next = new Set<string>(multi ? selected.value : []);
156
+ if (multi && next.has(n.path)) next.delete(n.path);
157
+ else next.add(n.path);
158
+ selected.value = next;
159
+ }
160
+
161
+ function onRowDbl(n: FileNode) {
162
+ if (n.type !== 'dir') return;
163
+ void loadPane(isStorageRow(n) ? n.path : props.toUser(n.path));
164
+ }
165
+
166
+ // Middle-button mousedown must be cancelled on rows: in a scrollable body
167
+ // Chromium's autoscroll takes over and auxclick is never generated.
168
+ function onRowMiddleDown(ev: MouseEvent) {
169
+ if (ev.button === 1) ev.preventDefault();
170
+ }
171
+
172
+ // Middle-click on a folder opens it in a NEW TAB (same convention as the
173
+ // main listing). stopPropagation keeps the host's delegated auxclick
174
+ // listener from double-handling it.
175
+ function onRowAux(n: FileNode, ev: MouseEvent) {
176
+ if (ev.button !== 1) return;
177
+ ev.preventDefault();
178
+ ev.stopPropagation();
179
+ if (n.type !== 'dir') return;
180
+ emit('open-tab', isStorageRow(n) ? n.path : props.toUser(n.path));
181
+ }
182
+
183
+ function clearSelection() {
184
+ selected.value = new Set();
185
+ }
186
+
187
+ // ------------------------------------------------------------------
188
+ // Drag source
189
+ // ------------------------------------------------------------------
190
+
191
+ function onRowDragStart(n: FileNode, ev: DragEvent) {
192
+ if (!ev.dataTransfer) return;
193
+ if (isStorageRow(n) || atVirtualRoot.value) {
194
+ ev.preventDefault();
195
+ return;
196
+ }
197
+ if (!selected.value.has(n.path)) selected.value = new Set([n.path]);
198
+ const items = files.value
199
+ .filter((f) => selected.value.has(f.path) && !isStorageRow(f))
200
+ .map((f) => ({ path: f.path, basename: f.basename, type: f.type }));
201
+ if (items.length === 0) {
202
+ ev.preventDefault();
203
+ return;
204
+ }
205
+ ev.dataTransfer.setData(FE_DND_MIME, JSON.stringify(items));
206
+ ev.dataTransfer.setData(FE_DND_SRC_MIME, props.qualify(path.value));
207
+ ev.dataTransfer.setData('text/plain', items.map((i) => i.path).join('\n'));
208
+ ev.dataTransfer.effectAllowed = 'move';
209
+ }
210
+
211
+ // ------------------------------------------------------------------
212
+ // Drop target (dir rows + pane background)
213
+ // ------------------------------------------------------------------
214
+
215
+ const dropPath = ref<string | null>(null);
216
+ const dropBg = ref(false);
217
+
218
+ function acceptDrag(ev: DragEvent): boolean {
219
+ return !!ev.dataTransfer?.types.includes(FE_DND_MIME);
220
+ }
221
+
222
+ function handleDropPayload(ev: DragEvent, targetWire: string) {
223
+ const raw = ev.dataTransfer?.getData(FE_DND_MIME);
224
+ if (!raw || !targetWire) return;
225
+ let items: Array<{ path: string }> = [];
226
+ try {
227
+ items = JSON.parse(raw);
228
+ } catch {
229
+ return;
230
+ }
231
+ const origin = ev.dataTransfer?.getData(FE_DND_SRC_MIME) || undefined;
232
+ const sources = items
233
+ .map((i) => i.path)
234
+ .filter((p) => p && p !== targetWire && !targetWire.startsWith(p + '/'));
235
+ if (sources.length === 0) return;
236
+ ev.preventDefault();
237
+ ev.stopPropagation();
238
+ emit('transfer', { sources, targetWire, originWire: origin });
239
+ }
240
+
241
+ function onRowDragOver(n: FileNode, ev: DragEvent) {
242
+ if (n.type !== 'dir' || isStorageRow(n)) return;
243
+ if (!acceptDrag(ev)) return;
244
+ ev.preventDefault();
245
+ ev.stopPropagation();
246
+ if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
247
+ dropPath.value = n.path;
248
+ dropBg.value = false;
249
+ }
250
+
251
+ function onRowDragLeave(n: FileNode) {
252
+ if (dropPath.value === n.path) dropPath.value = null;
253
+ }
254
+
255
+ function onRowDrop(n: FileNode, ev: DragEvent) {
256
+ dropPath.value = null;
257
+ dropBg.value = false;
258
+ if (n.type !== 'dir' || isStorageRow(n)) return;
259
+ if (!acceptDrag(ev)) return;
260
+ handleDropPayload(ev, n.path); // real rows are wire-qualified
261
+ }
262
+
263
+ function onBgDragOver(ev: DragEvent) {
264
+ if (!acceptDrag(ev) || atVirtualRoot.value) return;
265
+ ev.preventDefault();
266
+ ev.stopPropagation();
267
+ if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
268
+ dropBg.value = true;
269
+ }
270
+
271
+ function onBgDragLeave() {
272
+ dropBg.value = false;
273
+ }
274
+
275
+ function onBgDrop(ev: DragEvent) {
276
+ dropBg.value = false;
277
+ dropPath.value = null;
278
+ if (!acceptDrag(ev) || atVirtualRoot.value) return;
279
+ handleDropPayload(ev, props.qualify(path.value));
280
+ }
281
+
282
+ // ------------------------------------------------------------------
283
+ // Host API (keyboard routing when this pane is active)
284
+ // ------------------------------------------------------------------
285
+
286
+ function goUp() {
287
+ const cur = (path.value ?? '').replace(/^\/+|\/+$/g, '');
288
+ const floor = (props.floor || '').replace(/^\/+|\/+$/g, '');
289
+ if (!cur || cur === floor) return;
290
+ const idx = cur.lastIndexOf('/');
291
+ void loadPane(idx === -1 ? '' : cur.slice(0, idx));
292
+ }
293
+
294
+ function selectAll() {
295
+ selected.value = new Set(files.value.filter((f) => !isStorageRow(f)).map((f) => f.path));
296
+ }
297
+
298
+ function selectedNodes(): FileNode[] {
299
+ return files.value.filter((f) => selected.value.has(f.path));
300
+ }
301
+
302
+ function openSelected() {
303
+ const n = selectedNodes()[0];
304
+ if (n && n.type === 'dir') onRowDbl(n);
305
+ }
306
+
307
+ function reload(): Promise<void> {
308
+ return loadPane();
309
+ }
310
+
311
+ function getPath(): string {
312
+ return path.value;
313
+ }
314
+
315
+ defineExpose({ reload, goUp, selectAll, openSelected, selectedNodes, getPath });
316
+
317
+ function specialEmojiFor(n: FileNode): string | null {
318
+ return isStorageRow(n) ? '💾' : null;
319
+ }
320
+ </script>
321
+
322
+ <template>
323
+ <section
324
+ class="fe-split"
325
+ :class="{ 'fe-pane--focus': active }"
326
+ role="region"
327
+ :aria-label="t('split.pane')"
328
+ @pointerdown="emit('activate')"
329
+ >
330
+ <header class="fe-split__crumbs">
331
+ <button
332
+ v-if="showRootCrumb"
333
+ type="button"
334
+ class="fe-split__crumb"
335
+ :class="{ 'is-last': crumbs.length === 0 }"
336
+ :title="rootLabel"
337
+ @click="crumbGo('')"
338
+ >{{ rootLabel }}</button>
339
+ <template v-for="c in crumbs" :key="c.target">
340
+ <span class="fe-split__sep" aria-hidden="true">›</span>
341
+ <button
342
+ type="button"
343
+ class="fe-split__crumb"
344
+ :class="{ 'is-last': c.last }"
345
+ :title="c.label"
346
+ @click="crumbGo(c.target)"
347
+ >{{ c.label }}</button>
348
+ </template>
349
+ <span class="fe-split__spacer"></span>
350
+ <button
351
+ type="button"
352
+ class="fe-split__closebtn"
353
+ :aria-label="t('split.close')"
354
+ :title="t('split.close')"
355
+ @click="emit('close')"
356
+ >×</button>
357
+ </header>
358
+
359
+ <div
360
+ class="fe-split__body"
361
+ :class="{ 'is-dropover': dropBg }"
362
+ @click.self="clearSelection"
363
+ @dragover="onBgDragOver"
364
+ @dragleave="onBgDragLeave"
365
+ @drop="onBgDrop"
366
+ >
367
+ <div v-if="loading && files.length === 0" class="fe-split__state" role="status">
368
+ {{ t('loading') }}
369
+ </div>
370
+ <div v-else-if="error" class="fe-split__state">
371
+ <span>{{ t('split.error') }}</span>
372
+ <button type="button" class="fe-btn" @click="() => loadPane()">
373
+ {{ t('split.retry') }}
374
+ </button>
375
+ </div>
376
+ <div v-else-if="files.length === 0" class="fe-split__state">
377
+ {{ t('empty.folder') }}
378
+ </div>
379
+ <div v-else class="fe-split__list" role="listbox" aria-multiselectable="true">
380
+ <div
381
+ v-for="n in files"
382
+ :key="n.path"
383
+ class="fe-split__row"
384
+ :class="{
385
+ 'is-selected': selected.has(n.path),
386
+ 'is-dir': n.type === 'dir',
387
+ 'is-droptarget': dropPath === n.path,
388
+ }"
389
+ role="option"
390
+ :aria-selected="selected.has(n.path) ? 'true' : 'false'"
391
+ tabindex="0"
392
+ draggable="true"
393
+ @click="onRowClick(n, $event)"
394
+ @dblclick="onRowDbl(n)"
395
+ @mousedown="onRowMiddleDown($event)"
396
+ @auxclick="onRowAux(n, $event)"
397
+ @dragstart="onRowDragStart(n, $event)"
398
+ @dragover="onRowDragOver(n, $event)"
399
+ @dragleave="onRowDragLeave(n)"
400
+ @drop="onRowDrop(n, $event)"
401
+ >
402
+ <span v-if="specialEmojiFor(n)" class="fe-split__icon" aria-hidden="true">{{ specialEmojiFor(n) }}</span>
403
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
404
+ <span v-else class="fe-split__icon" aria-hidden="true" v-html="fileIconSvg(n)"></span>
405
+ <span class="fe-split__name" :title="n.basename">{{ nodeDisplayName(n) }}</span>
406
+ <span class="fe-split__size">{{ n.type === 'dir' ? '' : formatSize(n.size) }}</span>
407
+ </div>
408
+ </div>
409
+ </div>
410
+ </section>
411
+ </template>
@@ -0,0 +1,174 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * TabBar — wiring:d1 sekme şeridi.
4
+ *
5
+ * Thin strip that sits ABOVE the toolbar. The host renders it only when
6
+ * 2+ tabs exist (embed pixel-parity: a single tab shows nothing at all).
7
+ * Purely presentational: every mutation is an emit; useTabs owns state.
8
+ *
9
+ * Interactions:
10
+ * click tab → select
11
+ * × / middle-tık → close
12
+ * + → new tab (clone of the current location)
13
+ * drag → reorder (HTML5 DnD; live-swap while dragging)
14
+ * ⫿ (split) → toggle the active tab's secondary pane
15
+ *
16
+ * UNSCOPED styles (`fe-tabs*` in base.css) — the webcomponent build
17
+ * cannot carry scoped data-v hashes (v0.5.0 discovery).
18
+ */
19
+ import { ref } from 'vue';
20
+ import type { LocaleCode } from '../types/ExplorerConfig';
21
+ import { useLocale } from '../composables/useLocale';
22
+
23
+ export interface TabItem {
24
+ id: string;
25
+ label: string;
26
+ /** true → the tab carries a split pane (small accent dot). */
27
+ split: boolean;
28
+ }
29
+
30
+ const props = defineProps<{
31
+ tabs: TabItem[];
32
+ activeId: string;
33
+ locale: LocaleCode;
34
+ /** false → the split toggle is hidden (narrow embeds). */
35
+ splitEnabled?: boolean;
36
+ /** true → the split toggle renders pressed (active tab has a pane). */
37
+ splitActive?: boolean;
38
+ }>();
39
+
40
+ const emit = defineEmits<{
41
+ (e: 'select', id: string): void;
42
+ (e: 'close', id: string): void;
43
+ (e: 'new'): void;
44
+ (e: 'reorder', from: number, to: number): void;
45
+ (e: 'toggle-split'): void;
46
+ }>();
47
+
48
+ const { t } = useLocale(() => props.locale);
49
+
50
+ // ---- drag-sort (HTML5 DnD, live swap) ------------------------------
51
+ const dragIndex = ref<number | null>(null);
52
+ const overIndex = ref<number | null>(null);
53
+
54
+ function onDragStart(i: number, ev: DragEvent) {
55
+ dragIndex.value = i;
56
+ if (ev.dataTransfer) {
57
+ ev.dataTransfer.effectAllowed = 'move';
58
+ // Firefox requires SOME payload for a drag to start. Deliberately not
59
+ // the internal file-DnD MIME, so the explorer's drop surfaces ignore it.
60
+ ev.dataTransfer.setData('text/plain', props.tabs[i]?.id ?? '');
61
+ }
62
+ }
63
+
64
+ function onDragOver(i: number, ev: DragEvent) {
65
+ if (dragIndex.value === null) return;
66
+ ev.preventDefault();
67
+ ev.stopPropagation();
68
+ if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
69
+ overIndex.value = i;
70
+ }
71
+
72
+ function onDragLeave(i: number) {
73
+ if (overIndex.value === i) overIndex.value = null;
74
+ }
75
+
76
+ function onDrop(i: number, ev: DragEvent) {
77
+ if (dragIndex.value === null) return;
78
+ ev.preventDefault();
79
+ ev.stopPropagation();
80
+ if (dragIndex.value !== i) emit('reorder', dragIndex.value, i);
81
+ dragIndex.value = null;
82
+ overIndex.value = null;
83
+ }
84
+
85
+ function onDragEnd() {
86
+ dragIndex.value = null;
87
+ overIndex.value = null;
88
+ }
89
+
90
+ // Middle-click closes a tab (browser convention). The mousedown guard
91
+ // cancels Chromium's autoscroll on the scrollable strip — without it the
92
+ // auxclick event is never generated.
93
+ function onTabMiddleDown(ev: MouseEvent) {
94
+ if (ev.button === 1) ev.preventDefault();
95
+ }
96
+ function onAux(id: string, ev: MouseEvent) {
97
+ if (ev.button !== 1) return;
98
+ ev.preventDefault();
99
+ ev.stopPropagation();
100
+ emit('close', id);
101
+ }
102
+ </script>
103
+
104
+ <template>
105
+ <div class="fe-tabs" role="tablist" :aria-label="t('tabs.strip')">
106
+ <div class="fe-tabs__scroll">
107
+ <div
108
+ v-for="(tab, i) in tabs"
109
+ :key="tab.id"
110
+ class="fe-tabs__tab"
111
+ :class="{
112
+ 'is-active': tab.id === activeId,
113
+ 'is-dragover': overIndex === i && dragIndex !== null && dragIndex !== i,
114
+ }"
115
+ role="tab"
116
+ :aria-selected="tab.id === activeId ? 'true' : 'false'"
117
+ tabindex="0"
118
+ draggable="true"
119
+ :title="tab.label"
120
+ @click="emit('select', tab.id)"
121
+ @keydown.enter.prevent="emit('select', tab.id)"
122
+ @mousedown="onTabMiddleDown($event)"
123
+ @auxclick="onAux(tab.id, $event)"
124
+ @dragstart="onDragStart(i, $event)"
125
+ @dragover="onDragOver(i, $event)"
126
+ @dragleave="onDragLeave(i)"
127
+ @drop="onDrop(i, $event)"
128
+ @dragend="onDragEnd"
129
+ >
130
+ <span v-if="tab.split" class="fe-tabs__splitdot" aria-hidden="true"></span>
131
+ <span class="fe-tabs__label">{{ tab.label }}</span>
132
+ <button
133
+ type="button"
134
+ class="fe-tabs__close"
135
+ :aria-label="t('tabs.close')"
136
+ :title="t('tabs.close')"
137
+ @click.stop="emit('close', tab.id)"
138
+ >×</button>
139
+ </div>
140
+ <button
141
+ type="button"
142
+ class="fe-tabs__new"
143
+ :aria-label="t('tabs.new')"
144
+ :title="t('tabs.new')"
145
+ @click="emit('new')"
146
+ >+</button>
147
+ </div>
148
+ <button
149
+ v-if="splitEnabled !== false"
150
+ type="button"
151
+ class="fe-tabs__split"
152
+ :class="{ 'is-active': splitActive }"
153
+ :aria-label="splitActive ? t('tabs.split_off') : t('tabs.split')"
154
+ :title="splitActive ? t('tabs.split_off') : t('tabs.split')"
155
+ :aria-pressed="splitActive ? 'true' : 'false'"
156
+ @click="emit('toggle-split')"
157
+ >
158
+ <svg
159
+ class="fe-ficon"
160
+ viewBox="0 0 24 24"
161
+ fill="none"
162
+ stroke="currentColor"
163
+ stroke-width="1.8"
164
+ stroke-linecap="round"
165
+ stroke-linejoin="round"
166
+ aria-hidden="true"
167
+ focusable="false"
168
+ >
169
+ <rect x="3" y="4" width="18" height="16" rx="2" />
170
+ <path d="M12 4v16" />
171
+ </svg>
172
+ </button>
173
+ </div>
174
+ </template>
@@ -221,11 +221,12 @@ const moreActions = computed<ContextAction[]>(() => {
221
221
  : t('toolbar.density.compact'),
222
222
  icon: '⇅',
223
223
  });
224
- list.push(
225
- props.viewMode === 'list'
226
- ? { key: 'view-grid', label: t('toolbar.view.grid'), icon: '' }
227
- : { key: 'view-list', label: t('toolbar.view.list'), icon: '' },
228
- );
224
+ /* wiring:d2 — dar mod ⋯ menüsü: aktif olmayan İKİ görünüm de listelenir
225
+ (list/grid/gallery); eski tekli toggle üç modda eksik kalıyordu. */
226
+ if (props.viewMode !== 'list') list.push({ key: 'view-list', label: t('toolbar.view.list'), icon: '' });
227
+ if (props.viewMode !== 'grid') list.push({ key: 'view-grid', label: t('toolbar.view.grid'), icon: '' });
228
+ if (props.viewMode !== 'gallery') list.push({ key: 'view-gallery', label: t('toolbar.view.gallery'), icon: '▣' });
229
+ /* /wiring:d2 */
229
230
  /* koru:k1 — inspector toggle also reachable from the narrow overflow menu */
230
231
  list.push({ key: 'inspector', label: t('toolbar.inspector'), icon: 'ℹ' });
231
232
  /* wiring:c1 — tema galerisi de dar modda ⋯ menüsünden açılır */
@@ -257,6 +258,9 @@ function onMoreSelect(a: ContextAction) {
257
258
  case 'view-grid':
258
259
  emit('update:viewMode', 'grid');
259
260
  break;
261
+ case 'view-gallery' /* wiring:d2 */:
262
+ emit('update:viewMode', 'gallery');
263
+ break;
260
264
  case 'inspector' /* koru:k1 */:
261
265
  emit('toggle-inspector');
262
266
  break;
@@ -487,6 +491,20 @@ function onMoreSelect(a: ContextAction) {
487
491
  >
488
492
  <span class="fe-icon" aria-hidden="true">▦</span>
489
493
  </button>
494
+ <!-- wiring:d2 — üçüncü görünüm: galeri -->
495
+ <button
496
+ type="button"
497
+ class="fe-btn fe-btn--icon-only"
498
+ :class="{ 'is-active': viewMode === 'gallery' }"
499
+ role="tab"
500
+ :aria-selected="viewMode === 'gallery'"
501
+ :title="t('toolbar.view.gallery')"
502
+ :aria-label="t('toolbar.view.gallery')"
503
+ @click="emit('update:viewMode', 'gallery')"
504
+ >
505
+ <span class="fe-icon" aria-hidden="true">▣</span>
506
+ </button>
507
+ <!-- /wiring:d2 -->
490
508
  </div>
491
509
  </template>
492
510