quasar-ui-danx 0.4.91 → 0.4.93

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 (31) hide show
  1. package/dist/danx.es.js +7369 -6609
  2. package/dist/danx.es.js.map +1 -1
  3. package/dist/danx.umd.js +90 -90
  4. package/dist/danx.umd.js.map +1 -1
  5. package/dist/style.css +1 -1
  6. package/package.json +1 -1
  7. package/src/components/Utility/Dialogs/FullscreenCarouselDialog.vue +123 -136
  8. package/src/components/Utility/Files/CarouselHeader.vue +80 -0
  9. package/src/components/Utility/Files/FilePreview.vue +76 -21
  10. package/src/components/Utility/Files/FileRenderer.vue +111 -0
  11. package/src/components/Utility/Files/ThumbnailStrip.vue +64 -0
  12. package/src/components/Utility/Files/TranscodeNavigator.vue +175 -0
  13. package/src/components/Utility/Files/VirtualCarousel.vue +237 -0
  14. package/src/components/Utility/Files/index.ts +5 -0
  15. package/src/components/Utility/Widgets/LabelPillWidget.vue +10 -0
  16. package/src/composables/index.ts +4 -0
  17. package/src/composables/useFileNavigation.ts +129 -0
  18. package/src/composables/useKeyboardNavigation.ts +58 -0
  19. package/src/composables/useThumbnailScroll.ts +43 -0
  20. package/src/composables/useVirtualCarousel.ts +93 -0
  21. package/src/helpers/filePreviewHelpers.ts +107 -0
  22. package/src/helpers/formats/datetime.ts +285 -0
  23. package/src/helpers/formats/index.ts +4 -0
  24. package/src/helpers/formats/numbers.ts +127 -0
  25. package/src/helpers/formats/parsers.ts +74 -0
  26. package/src/helpers/formats/strings.ts +65 -0
  27. package/src/helpers/formats.ts +1 -489
  28. package/src/helpers/index.ts +1 -0
  29. package/src/types/files.d.ts +38 -0
  30. package/src/types/widgets.d.ts +1 -1
  31. package/src/vue-plugin.ts +1 -0
@@ -0,0 +1,64 @@
1
+ <template>
2
+ <div
3
+ v-if="files.length > 1"
4
+ class="bg-slate-900 bg-opacity-90 p-3 flex-shrink-0"
5
+ >
6
+ <div
7
+ ref="containerRef"
8
+ class="flex items-center justify-start gap-2 overflow-x-auto overflow-y-hidden px-4"
9
+ >
10
+ <div
11
+ v-for="(file, index) in files"
12
+ :key="file.id"
13
+ :class="[
14
+ 'thumbnail cursor-pointer rounded border-2 transition-all flex-shrink-0 relative',
15
+ index === currentIndex ? 'border-blue-500 scale-110' : 'border-transparent opacity-60 hover:opacity-100'
16
+ ]"
17
+ @click="onThumbnailClick(index)"
18
+ >
19
+ <img
20
+ :src="getThumbUrl(file)"
21
+ :alt="file.filename || file.name"
22
+ class="w-16 h-16 object-cover rounded"
23
+ >
24
+ <div class="absolute bottom-0 left-0 bg-slate-900 bg-opacity-80 text-slate-200 text-xs px-1.5 py-0.5 rounded-br rounded-tl font-semibold">
25
+ {{ index + 1 }}
26
+ </div>
27
+ </div>
28
+ </div>
29
+ </div>
30
+ </template>
31
+
32
+ <script setup lang="ts">
33
+ import { ref, toRef } from "vue";
34
+ import { useThumbnailScroll } from "../../../composables/useThumbnailScroll";
35
+ import { getThumbUrl } from "../../../helpers/filePreviewHelpers";
36
+ import { UploadedFile } from "../../../types";
37
+
38
+ const emit = defineEmits<{
39
+ 'navigate': [index: number];
40
+ }>();
41
+
42
+ const props = defineProps<{
43
+ files: UploadedFile[];
44
+ currentIndex: number;
45
+ }>();
46
+
47
+ const containerRef = ref<HTMLElement | null>(null);
48
+
49
+ // Auto-scroll active thumbnail into view
50
+ useThumbnailScroll({
51
+ containerRef,
52
+ currentIndex: toRef(props, "currentIndex")
53
+ });
54
+
55
+ function onThumbnailClick(index: number) {
56
+ emit("navigate", index);
57
+ }
58
+ </script>
59
+
60
+ <style scoped lang="scss">
61
+ .thumbnail {
62
+ transition: all 0.2s ease;
63
+ }
64
+ </style>
@@ -0,0 +1,175 @@
1
+ <template>
2
+ <div class="transcode-navigator">
3
+ <!-- Desktop: Popover Menu -->
4
+ <QMenu
5
+ v-if="!isMobile"
6
+ v-model="isOpen"
7
+ anchor="top right"
8
+ self="bottom right"
9
+ :offset="[0, 8]"
10
+ >
11
+ <div class="bg-slate-800 rounded-lg shadow-xl p-3 min-w-[300px] max-w-[400px]">
12
+ <div class="text-slate-200 font-semibold mb-2 px-2">
13
+ Transcodes ({{ transcodes.length }})
14
+ </div>
15
+ <div class="flex flex-wrap gap-2">
16
+ <div
17
+ v-for="(transcode, index) in transcodes"
18
+ :key="transcode.id"
19
+ class="transcode-thumb cursor-pointer rounded border-2 transition-all hover:scale-105"
20
+ :class="index === selectedIndex ? 'border-purple-500' : 'border-transparent opacity-70 hover:opacity-100'"
21
+ @click="onSelectTranscode(transcode, index)"
22
+ >
23
+ <div class="relative">
24
+ <img
25
+ :src="getThumbUrl(transcode)"
26
+ :alt="transcode.filename || transcode.name"
27
+ class="w-20 h-20 object-cover rounded"
28
+ >
29
+ <div
30
+ v-if="isTranscoding(transcode)"
31
+ class="absolute inset-0 bg-slate-900 bg-opacity-70 flex items-center justify-center rounded"
32
+ >
33
+ <QSpinnerPie
34
+ class="text-purple-400"
35
+ size="24px"
36
+ />
37
+ </div>
38
+ </div>
39
+ <div class="text-xs text-slate-400 text-center mt-1 truncate w-20">
40
+ {{ transcode.filename || transcode.name }}
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ </QMenu>
46
+
47
+ <!-- Mobile: Full Dialog -->
48
+ <QDialog
49
+ v-else
50
+ v-model="isOpen"
51
+ position="bottom"
52
+ >
53
+ <div class="bg-slate-800 rounded-t-2xl p-4">
54
+ <div class="flex items-center justify-between mb-4">
55
+ <div class="text-slate-200 font-semibold text-lg">
56
+ Transcodes ({{ transcodes.length }})
57
+ </div>
58
+ <QBtn
59
+ flat
60
+ round
61
+ dense
62
+ @click="isOpen = false"
63
+ >
64
+ <CloseIcon class="w-5 text-slate-400" />
65
+ </QBtn>
66
+ </div>
67
+ <div class="grid grid-cols-3 gap-3">
68
+ <div
69
+ v-for="(transcode, index) in transcodes"
70
+ :key="transcode.id"
71
+ class="transcode-thumb cursor-pointer rounded border-2 transition-all"
72
+ :class="index === selectedIndex ? 'border-purple-500' : 'border-transparent'"
73
+ @click="onSelectTranscode(transcode, index)"
74
+ >
75
+ <div class="relative">
76
+ <img
77
+ :src="getThumbUrl(transcode)"
78
+ :alt="transcode.filename || transcode.name"
79
+ class="w-full aspect-square object-cover rounded"
80
+ >
81
+ <div
82
+ v-if="isTranscoding(transcode)"
83
+ class="absolute inset-0 bg-slate-900 bg-opacity-70 flex items-center justify-center rounded"
84
+ >
85
+ <QSpinnerPie
86
+ class="text-purple-400"
87
+ size="32px"
88
+ />
89
+ </div>
90
+ </div>
91
+ <div class="text-xs text-slate-400 text-center mt-1 truncate">
92
+ {{ transcode.filename || transcode.name }}
93
+ </div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ </QDialog>
98
+ </div>
99
+ </template>
100
+
101
+ <script setup lang="ts">
102
+ import { XIcon as CloseIcon } from "@heroicons/vue/outline";
103
+ import { QSpinnerPie } from "quasar";
104
+ import { computed, ref } from "vue";
105
+ import { UploadedFile } from "../../../types";
106
+
107
+ interface TranscodeNavigatorProps {
108
+ transcodes: UploadedFile[];
109
+ modelValue?: boolean;
110
+ selectedIndex?: number;
111
+ }
112
+
113
+ const emit = defineEmits<{
114
+ 'update:modelValue': [value: boolean];
115
+ 'select': [transcode: UploadedFile, index: number];
116
+ }>();
117
+
118
+ const props = withDefaults(defineProps<TranscodeNavigatorProps>(), {
119
+ modelValue: false,
120
+ selectedIndex: -1
121
+ });
122
+
123
+ const isOpen = computed({
124
+ get: () => props.modelValue,
125
+ set: (value) => emit('update:modelValue', value)
126
+ });
127
+
128
+ // Detect mobile (simple breakpoint check)
129
+ const isMobile = computed(() => {
130
+ if (typeof window === 'undefined') return false;
131
+ return window.innerWidth < 768;
132
+ });
133
+
134
+ function onSelectTranscode(transcode: UploadedFile, index: number) {
135
+ emit('select', transcode, index);
136
+ isOpen.value = false;
137
+ }
138
+
139
+ function getThumbUrl(file: UploadedFile): string {
140
+ if (file.thumb?.url) {
141
+ return file.thumb.url;
142
+ }
143
+ if (isVideo(file)) {
144
+ // Placeholder for video
145
+ return `data:image/svg+xml;base64,${btoa(
146
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M8 5v14l11-7z"/></svg>'
147
+ )}`;
148
+ }
149
+ return file.optimized?.url || file.blobUrl || file.url || 'https://placehold.co/80x80?text=?';
150
+ }
151
+
152
+ function isVideo(file: UploadedFile): boolean {
153
+ return !!file.mime?.startsWith('video') || !!file.type?.startsWith('video');
154
+ }
155
+
156
+ function isTranscoding(file: UploadedFile): boolean {
157
+ // Check if file is still being transcoded based on meta
158
+ const metaTranscodes = file.meta?.transcodes || {};
159
+ for (const transcodeName of Object.keys(metaTranscodes)) {
160
+ const transcode = metaTranscodes[transcodeName];
161
+ if (transcode?.status && !['Complete', 'Timeout'].includes(transcode.status)) {
162
+ return true;
163
+ }
164
+ }
165
+ return false;
166
+ }
167
+ </script>
168
+
169
+ <style scoped lang="scss">
170
+ .transcode-navigator {
171
+ .transcode-thumb {
172
+ transition: all 0.2s ease;
173
+ }
174
+ }
175
+ </style>
@@ -0,0 +1,237 @@
1
+ <template>
2
+ <div class="virtual-carousel relative w-full h-full">
3
+ <!-- Carousel Container -->
4
+ <div
5
+ ref="carouselContainer"
6
+ class="carousel-slides-container w-full h-full flex items-center justify-center relative overflow-hidden"
7
+ @touchstart="onTouchStart"
8
+ @touchmove="onTouchMove"
9
+ @touchend="onTouchEnd"
10
+ >
11
+ <!-- Render only visible slides -->
12
+ <transition-group
13
+ name="slide"
14
+ tag="div"
15
+ class="w-full h-full relative"
16
+ >
17
+ <div
18
+ v-for="slide in visibleSlides"
19
+ :key="slide.file.id"
20
+ :class="[
21
+ 'carousel-slide absolute inset-0 flex items-center justify-center',
22
+ { 'active': slide.isActive, 'inactive': !slide.isActive }
23
+ ]"
24
+ >
25
+ <slot
26
+ name="slide"
27
+ :file="slide.file"
28
+ :index="slide.index"
29
+ :is-active="slide.isActive"
30
+ >
31
+ <!-- Default slide content -->
32
+ <FileRenderer :file="slide.file" />
33
+ </slot>
34
+ </div>
35
+ </transition-group>
36
+ </div>
37
+
38
+ <!-- Navigation Arrows -->
39
+ <div
40
+ v-if="canNavigatePrevious"
41
+ class="absolute left-2 top-1/2 -translate-y-1/2 z-10"
42
+ >
43
+ <QBtn
44
+ round
45
+ size="lg"
46
+ class="bg-slate-800 text-white opacity-70 hover:opacity-100"
47
+ @click="onPrevious"
48
+ >
49
+ <ChevronLeftIcon class="w-6" />
50
+ </QBtn>
51
+ </div>
52
+ <div
53
+ v-if="canNavigateNext"
54
+ class="absolute right-2 top-1/2 -translate-y-1/2 z-10"
55
+ >
56
+ <QBtn
57
+ round
58
+ size="lg"
59
+ class="bg-slate-800 text-white opacity-70 hover:opacity-100"
60
+ @click="onNext"
61
+ >
62
+ <ChevronRightIcon class="w-6" />
63
+ </QBtn>
64
+ </div>
65
+
66
+ <!-- Thumbnail Navigation (using ThumbnailStrip component) -->
67
+ <div
68
+ v-if="showThumbnails && files.length > 1"
69
+ class="absolute bottom-0 left-0 right-0"
70
+ >
71
+ <ThumbnailStrip
72
+ :files="files"
73
+ :current-index="currentIndex"
74
+ @navigate="navigateTo"
75
+ />
76
+ </div>
77
+
78
+ <!-- Slide Counter -->
79
+ <div
80
+ v-if="showCounter && files.length > 1"
81
+ class="absolute top-2 right-2 bg-slate-900 bg-opacity-70 text-slate-200 px-3 py-1 rounded-full text-sm"
82
+ >
83
+ {{ currentIndex + 1 }} / {{ files.length }}
84
+ </div>
85
+ </div>
86
+ </template>
87
+
88
+ <script setup lang="ts">
89
+ import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/vue/outline";
90
+ import { computed, ref, toRef, watch } from "vue";
91
+ import { useKeyboardNavigation } from "../../../composables/useKeyboardNavigation";
92
+ import { useVirtualCarousel } from "../../../composables/useVirtualCarousel";
93
+ import { UploadedFile } from "../../../types";
94
+ import FileRenderer from "./FileRenderer.vue";
95
+ import ThumbnailStrip from "./ThumbnailStrip.vue";
96
+
97
+ interface VirtualCarouselProps {
98
+ files: UploadedFile[];
99
+ modelValue?: number;
100
+ showThumbnails?: boolean;
101
+ showCounter?: boolean;
102
+ enableKeyboard?: boolean;
103
+ enableSwipe?: boolean;
104
+ }
105
+
106
+ const emit = defineEmits<{
107
+ 'update:modelValue': [index: number];
108
+ 'change': [file: UploadedFile, index: number];
109
+ }>();
110
+
111
+ const props = withDefaults(defineProps<VirtualCarouselProps>(), {
112
+ modelValue: 0,
113
+ showThumbnails: true,
114
+ showCounter: true,
115
+ enableKeyboard: true,
116
+ enableSwipe: true
117
+ });
118
+
119
+ const carouselContainer = ref<HTMLElement | null>(null);
120
+ const currentIndex = ref(props.modelValue);
121
+ const files = computed(() => props.files);
122
+
123
+ // Use virtual carousel composable
124
+ const { visibleSlides } = useVirtualCarousel(files, currentIndex);
125
+
126
+ // Navigation
127
+ const canNavigatePrevious = computed(() => currentIndex.value > 0);
128
+ const canNavigateNext = computed(() => currentIndex.value < props.files.length - 1);
129
+
130
+ function navigateTo(index: number) {
131
+ if (index >= 0 && index < props.files.length) {
132
+ currentIndex.value = index;
133
+ emit('update:modelValue', index);
134
+ emit('change', props.files[index], index);
135
+ }
136
+ }
137
+
138
+ function onPrevious() {
139
+ if (canNavigatePrevious.value) {
140
+ navigateTo(currentIndex.value - 1);
141
+ }
142
+ }
143
+
144
+ function onNext() {
145
+ if (canNavigateNext.value) {
146
+ navigateTo(currentIndex.value + 1);
147
+ }
148
+ }
149
+
150
+ // Keyboard navigation
151
+ useKeyboardNavigation({
152
+ onPrevious,
153
+ onNext
154
+ }, {
155
+ enabled: toRef(props, 'enableKeyboard')
156
+ });
157
+
158
+ // Touch/swipe support
159
+ const touchStart = ref<{ x: number; y: number } | null>(null);
160
+ const SWIPE_THRESHOLD = 50;
161
+
162
+ function onTouchStart(e: TouchEvent) {
163
+ if (!props.enableSwipe) return;
164
+ touchStart.value = {
165
+ x: e.touches[0].clientX,
166
+ y: e.touches[0].clientY
167
+ };
168
+ }
169
+
170
+ function onTouchMove(e: TouchEvent) {
171
+ if (!props.enableSwipe || !touchStart.value) return;
172
+
173
+ const deltaX = Math.abs(e.touches[0].clientX - touchStart.value.x);
174
+ const deltaY = Math.abs(e.touches[0].clientY - touchStart.value.y);
175
+
176
+ // Prevent vertical scroll if horizontal swipe detected
177
+ if (deltaX > deltaY) {
178
+ e.preventDefault();
179
+ }
180
+ }
181
+
182
+ function onTouchEnd(e: TouchEvent) {
183
+ if (!props.enableSwipe || !touchStart.value) return;
184
+
185
+ const deltaX = e.changedTouches[0].clientX - touchStart.value.x;
186
+
187
+ if (Math.abs(deltaX) > SWIPE_THRESHOLD) {
188
+ if (deltaX > 0) {
189
+ onPrevious();
190
+ } else {
191
+ onNext();
192
+ }
193
+ }
194
+
195
+ touchStart.value = null;
196
+ }
197
+
198
+ // Watch for external changes to modelValue
199
+ watch(() => props.modelValue, (newIndex) => {
200
+ if (newIndex !== currentIndex.value) {
201
+ currentIndex.value = newIndex;
202
+ }
203
+ });
204
+ </script>
205
+
206
+ <style scoped lang="scss">
207
+ .virtual-carousel {
208
+ .carousel-slide {
209
+ transition: opacity 0.3s ease;
210
+
211
+ &.inactive {
212
+ opacity: 0;
213
+ pointer-events: none;
214
+ }
215
+
216
+ &.active {
217
+ opacity: 1;
218
+ pointer-events: auto;
219
+ }
220
+ }
221
+
222
+ .thumbnail {
223
+ flex-shrink: 0;
224
+ }
225
+ }
226
+
227
+ // Slide transitions
228
+ .slide-enter-active,
229
+ .slide-leave-active {
230
+ transition: opacity 0.3s ease;
231
+ }
232
+
233
+ .slide-enter-from,
234
+ .slide-leave-to {
235
+ opacity: 0;
236
+ }
237
+ </style>
@@ -1,2 +1,7 @@
1
+ export { default as CarouselHeader } from "./CarouselHeader.vue";
1
2
  export { default as FilePreview } from "./FilePreview.vue";
3
+ export { default as FileRenderer } from "./FileRenderer.vue";
2
4
  export { default as SvgImg } from "./SvgImg.vue";
5
+ export { default as ThumbnailStrip } from "./ThumbnailStrip.vue";
6
+ export { default as TranscodeNavigator } from "./TranscodeNavigator.vue";
7
+ export { default as VirtualCarousel } from "./VirtualCarousel.vue";
@@ -20,9 +20,19 @@ const colorClasses = {
20
20
  amber: "bg-amber-950 text-amber-400",
21
21
  yellow: "bg-yellow-950 text-yellow-400",
22
22
  blue: "bg-blue-950 text-blue-400",
23
+ purple: "bg-purple-950 text-purple-400",
23
24
  slate: "bg-slate-950 text-slate-400",
24
25
  "slate-mid": "bg-slate-800 text-slate-400",
25
26
  gray: "bg-slate-700 text-gray-300",
27
+ emerald: "bg-emerald-950 text-emerald-400",
28
+ orange: "bg-orange-950 text-orange-400",
29
+ lime: "bg-lime-950 text-lime-400",
30
+ teal: "bg-teal-950 text-teal-400",
31
+ cyan: "bg-cyan-950 text-cyan-400",
32
+ rose: "bg-rose-950 text-rose-400",
33
+ indigo: "bg-indigo-950 text-indigo-400",
34
+ violet: "bg-violet-950 text-violet-400",
35
+ fuchsia: "bg-fuchsia-950 text-fuchsia-400",
26
36
  none: ""
27
37
  };
28
38
 
@@ -0,0 +1,4 @@
1
+ export * from "./useFileNavigation";
2
+ export * from "./useKeyboardNavigation";
3
+ export * from "./useThumbnailScroll";
4
+ export * from "./useVirtualCarousel";
@@ -0,0 +1,129 @@
1
+ import { computed, ref, Ref } from "vue";
2
+ import { FileNavigationParent, FileNavigationState, UploadedFile } from "../types";
3
+
4
+ /**
5
+ * Composable for managing file navigation state with parent stack support
6
+ * Enables diving into related files (like transcodes) and navigating back to parent
7
+ */
8
+ export function useFileNavigation(initialFile: Ref<UploadedFile | null>, initialRelatedFiles: Ref<UploadedFile[]> = ref([])) {
9
+ // Current navigation state
10
+ const currentFile = ref<UploadedFile | null>(initialFile.value);
11
+ const relatedFiles = ref<UploadedFile[]>(initialRelatedFiles.value);
12
+ const parentStack = ref<FileNavigationParent[]>([]);
13
+ const currentIndex = ref(0);
14
+
15
+ // Computed properties
16
+ const hasParent = computed(() => parentStack.value.length > 0);
17
+ const canNavigatePrevious = computed(() => currentIndex.value > 0);
18
+ const canNavigateNext = computed(() => currentIndex.value < relatedFiles.value.length - 1);
19
+ const totalFiles = computed(() => relatedFiles.value.length);
20
+
21
+ /**
22
+ * Navigate to a specific file by index
23
+ */
24
+ function navigateTo(index: number) {
25
+ if (index >= 0 && index < relatedFiles.value.length) {
26
+ currentIndex.value = index;
27
+ currentFile.value = relatedFiles.value[index];
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Navigate to the next file
33
+ */
34
+ function navigateNext() {
35
+ if (canNavigateNext.value) {
36
+ navigateTo(currentIndex.value + 1);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Navigate to the previous file
42
+ */
43
+ function navigatePrevious() {
44
+ if (canNavigatePrevious.value) {
45
+ navigateTo(currentIndex.value - 1);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Dive into a related file (e.g., transcodes)
51
+ * Pushes current state onto parent stack
52
+ */
53
+ function diveInto(file: UploadedFile, newRelatedFiles: UploadedFile[]) {
54
+ // Push current state onto parent stack
55
+ parentStack.value.push({
56
+ file: currentFile.value!,
57
+ relatedFiles: relatedFiles.value,
58
+ index: currentIndex.value
59
+ });
60
+
61
+ // Update to new state
62
+ currentFile.value = file;
63
+ relatedFiles.value = newRelatedFiles;
64
+ currentIndex.value = newRelatedFiles.findIndex(f => f.id === file.id);
65
+
66
+ // Fallback to 0 if not found
67
+ if (currentIndex.value === -1) {
68
+ currentIndex.value = 0;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Navigate back to parent file
74
+ * Pops state from parent stack
75
+ */
76
+ function navigateToParent() {
77
+ if (hasParent.value) {
78
+ const parent = parentStack.value.pop()!;
79
+ currentFile.value = parent.file;
80
+ relatedFiles.value = parent.relatedFiles;
81
+ currentIndex.value = parent.index;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Reset navigation state
87
+ */
88
+ function reset(file: UploadedFile | null = null, files: UploadedFile[] = []) {
89
+ currentFile.value = file;
90
+ relatedFiles.value = files;
91
+ parentStack.value = [];
92
+ currentIndex.value = 0;
93
+ }
94
+
95
+ /**
96
+ * Get navigation state snapshot
97
+ */
98
+ function getState(): FileNavigationState {
99
+ return {
100
+ currentFile: currentFile.value,
101
+ relatedFiles: relatedFiles.value,
102
+ parentStack: [...parentStack.value],
103
+ currentIndex: currentIndex.value
104
+ };
105
+ }
106
+
107
+ return {
108
+ // State
109
+ currentFile,
110
+ relatedFiles,
111
+ parentStack,
112
+ currentIndex,
113
+
114
+ // Computed
115
+ hasParent,
116
+ canNavigatePrevious,
117
+ canNavigateNext,
118
+ totalFiles,
119
+
120
+ // Methods
121
+ navigateTo,
122
+ navigateNext,
123
+ navigatePrevious,
124
+ diveInto,
125
+ navigateToParent,
126
+ reset,
127
+ getState
128
+ };
129
+ }
@@ -0,0 +1,58 @@
1
+ import { onMounted, onUnmounted } from "vue";
2
+
3
+ export interface KeyboardNavigationCallbacks {
4
+ onPrevious: () => void;
5
+ onNext: () => void;
6
+ onEscape?: () => void;
7
+ }
8
+
9
+ export interface UseKeyboardNavigationOptions {
10
+ enabled?: boolean;
11
+ }
12
+
13
+ /**
14
+ * Composable for keyboard navigation (arrow keys + escape)
15
+ * Handles left/right arrow keys for navigation and optional escape key
16
+ */
17
+ export function useKeyboardNavigation(
18
+ callbacks: KeyboardNavigationCallbacks,
19
+ options: UseKeyboardNavigationOptions = {}
20
+ ) {
21
+ const { enabled = true } = options;
22
+
23
+ function onKeydown(e: KeyboardEvent) {
24
+ if (!enabled) return;
25
+
26
+ switch (e.key) {
27
+ case "ArrowLeft":
28
+ e.preventDefault();
29
+ callbacks.onPrevious();
30
+ break;
31
+ case "ArrowRight":
32
+ e.preventDefault();
33
+ callbacks.onNext();
34
+ break;
35
+ case "Escape":
36
+ if (callbacks.onEscape) {
37
+ callbacks.onEscape();
38
+ }
39
+ break;
40
+ }
41
+ }
42
+
43
+ onMounted(() => {
44
+ if (enabled) {
45
+ window.addEventListener("keydown", onKeydown);
46
+ }
47
+ });
48
+
49
+ onUnmounted(() => {
50
+ if (enabled) {
51
+ window.removeEventListener("keydown", onKeydown);
52
+ }
53
+ });
54
+
55
+ return {
56
+ onKeydown
57
+ };
58
+ }