@fy-/fws-vue 2.3.11 → 2.3.13

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.
@@ -4,6 +4,7 @@ import { getURL, stringHash } from '@fy-/fws-js'
4
4
  import { ChevronRightIcon, HomeIcon } from '@heroicons/vue/24/solid'
5
5
  import { defineBreadcrumb } from '@unhead/schema-org'
6
6
  import { useSchemaOrg } from '@unhead/schema-org/vue'
7
+ import { computed } from 'vue'
7
8
 
8
9
  const props = withDefaults(
9
10
  defineProps<{
@@ -16,27 +17,46 @@ const props = withDefaults(
16
17
  },
17
18
  )
18
19
 
19
- const breadcrumbsSchemaFormat = props.nav.map((item, index) => {
20
- const fullUrl = `${
21
- getURL().Host
22
- }${item.to}`.replace(/\/\//g, '/')
20
+ // Memoize URL computation
21
+ const baseUrl = computed(() => {
22
+ const url = getURL()
23
+ return {
24
+ host: url.Host,
25
+ scheme: url.Scheme,
26
+ }
27
+ })
28
+
29
+ // Memoize breadcrumb schema format to avoid recalculation
30
+ const breadcrumbsSchemaFormat = computed(() => props.nav.map((item, index) => {
31
+ if (!item.to) {
32
+ return {
33
+ 'position': index + 1,
34
+ 'name': item.name,
35
+ '@type': 'ListItem',
36
+ }
37
+ }
38
+
39
+ const fullUrl = `${baseUrl.value.host}${item.to}`.replace(/\/\//g, '/')
23
40
  return {
24
41
  'position': index + 1,
25
42
  'name': item.name,
26
- 'item': item.to
27
- ? `${getURL().Scheme}://${fullUrl}`
28
- : undefined,
43
+ 'item': `${baseUrl.value.scheme}://${fullUrl}`,
29
44
  '@type': 'ListItem',
30
45
  }
31
- })
32
- function getBreadcrumbID() {
46
+ }))
47
+
48
+ // Cache breadcrumb ID to avoid string operations on every render
49
+ const breadcrumbId = computed(() => {
50
+ if (!props.nav.length) return ''
33
51
  const chain = props.nav.map(item => item.name).join(' > ')
34
52
  return stringHash(chain)
35
- }
53
+ })
54
+
55
+ // Only run schema.org setup if we have breadcrumbs
36
56
  if (props.nav && props.nav.length) {
37
57
  useSchemaOrg([
38
58
  defineBreadcrumb({
39
- '@id': `#${getBreadcrumbID()}`,
59
+ '@id': `#${breadcrumbId.value}`,
40
60
  'itemListElement': breadcrumbsSchemaFormat,
41
61
  }),
42
62
  ])
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { useDebounceFn } from '@vueuse/core'
2
3
  import { nextTick, onMounted, onUnmounted, ref } from 'vue'
3
4
  import { useEventBus } from '../../composables/event-bus'
4
5
  import DefaultModal from './DefaultModal.vue'
@@ -17,12 +18,12 @@ interface ConfirmModalData {
17
18
  onConfirm: Function
18
19
  }
19
20
 
20
- async function _onConfirm() {
21
+ const _onConfirm = useDebounceFn(async () => {
21
22
  if (onConfirm.value) {
22
23
  await onConfirm.value()
23
24
  }
24
25
  resetConfirm()
25
- }
26
+ }, 300)
26
27
 
27
28
  function resetConfirm() {
28
29
  title.value = null
@@ -43,9 +44,8 @@ function showConfirm(data: ConfirmModalData) {
43
44
  // Emit event first to ensure it's registered before opening the modal
44
45
  eventBus.emit('confirmModal', true)
45
46
 
46
- // Force this to happen at the end of the event loop
47
- // to ensure it happens after any other modal operations
48
- setTimeout(() => {
47
+ // Use requestAnimationFrame instead of setTimeout for better performance
48
+ requestAnimationFrame(() => {
49
49
  isOpen.value = true
50
50
  eventBus.emit('confirmModal', true)
51
51
 
@@ -57,7 +57,7 @@ function showConfirm(data: ConfirmModalData) {
57
57
  catch {
58
58
  }
59
59
  })
60
- }, 0)
60
+ })
61
61
  }
62
62
 
63
63
  onMounted(() => {
@@ -1,4 +1,5 @@
1
1
  <script lang="ts" setup>
2
+ import { useDebounceFn } from '@vueuse/core'
2
3
  import { computed } from 'vue'
3
4
  import { DefaultInput } from '../..'
4
5
 
@@ -22,10 +23,15 @@ const props = withDefaults(
22
23
 
23
24
  const emit = defineEmits(['update:modelValue'])
24
25
 
26
+ // Use debounced emitter to reduce update frequency
27
+ const emitUpdate = useDebounceFn((value: DateInterval) => {
28
+ emit('update:modelValue', value)
29
+ }, 150)
30
+
25
31
  const model = computed({
26
32
  get: () => props.modelValue,
27
33
  set: (items) => {
28
- emit('update:modelValue', items)
34
+ emitUpdate(items)
29
35
  },
30
36
  })
31
37
  </script>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
- import { onMounted, onUnmounted, ref } from 'vue'
2
+ import { onClickOutside, useEventListener } from '@vueuse/core'
3
+ import { onMounted, shallowRef } from 'vue'
3
4
  import ScaleTransition from './transitions/ScaleTransition.vue'
4
5
 
5
6
  const props = defineProps<{
@@ -16,31 +17,22 @@ const props = defineProps<{
16
17
  closeDropdown: () => void
17
18
  }>()
18
19
 
19
- const dropdownRef = ref<HTMLElement | null>(null)
20
-
21
- // Custom implementation of click-outside functionality
22
- function handleClickOutsideElement(event: MouseEvent) {
23
- if (props.preventClickOutside) return
24
-
25
- if (props.show && dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
26
- props.handleClickOutside()
27
- }
28
- }
29
-
30
- function handleCloseOnEscape(event: KeyboardEvent) {
31
- if (['Escape', 'Esc'].includes(event.key)) {
32
- props.closeDropdown()
33
- }
34
- }
20
+ const dropdownRef = shallowRef<HTMLElement | null>(null)
35
21
 
22
+ // Use VueUse's onClickOutside for more efficient click handling
36
23
  onMounted(() => {
37
- document.addEventListener('keydown', handleCloseOnEscape)
38
- document.addEventListener('click', handleClickOutsideElement)
39
- })
24
+ onClickOutside(dropdownRef, (_event) => {
25
+ if (!props.preventClickOutside && props.show) {
26
+ props.handleClickOutside()
27
+ }
28
+ })
40
29
 
41
- onUnmounted(() => {
42
- document.removeEventListener('keydown', handleCloseOnEscape)
43
- document.removeEventListener('click', handleClickOutsideElement)
30
+ // Use VueUse's useEventListener for cleaner event management
31
+ useEventListener(document, 'keydown', (event: KeyboardEvent) => {
32
+ if (['Escape', 'Esc'].includes(event.key) && props.show) {
33
+ props.closeDropdown()
34
+ }
35
+ })
44
36
  })
45
37
  </script>
46
38
 
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { useDebounceFn } from '@vueuse/core'
2
3
  import { computed } from 'vue'
3
4
 
4
5
  const props = defineProps<{
@@ -7,21 +8,28 @@ const props = defineProps<{
7
8
  color?: string
8
9
  }>()
9
10
 
11
+ // Fixed class strings to avoid string recreation on each render
10
12
  const baseClasses = `w-full px-4 py-3 flex items-center border-b opacity-60
11
- dark:opacity-70 outline-none text-sm border-fv-neutral-200 dark:border-fv-neutral-600
13
+ dark:opacity-70 outline-none text-sm border-fv-neutral-200 dark:border-fv-neutral-600
12
14
  transition-all duration-200`
13
15
 
14
- const colorClasses = computed(() => {
15
- if (props.color === 'danger') {
16
- return 'text-red-500 dark:hover:text-red-50 hover:bg-red-50 active:bg-red-100 dark:hover:bg-red-900'
17
- }
18
- else {
19
- return `text-black dark:text-white active:bg-fv-neutral-100 dark:hover:bg-fv-neutral-600
20
- dark:focus:bg-fv-neutral-600 hover:bg-fv-neutral-50`
21
- }
22
- })
16
+ // Cache color classes to avoid recomputation
17
+ const dangerClasses = 'text-red-500 dark:hover:text-red-50 hover:bg-red-50 active:bg-red-100 dark:hover:bg-red-900'
18
+ const defaultClasses = `text-black dark:text-white active:bg-fv-neutral-100 dark:hover:bg-fv-neutral-600
19
+ dark:focus:bg-fv-neutral-600 hover:bg-fv-neutral-50`
23
20
 
21
+ // Memoize color classes
22
+ const colorClasses = computed(() =>
23
+ props.color === 'danger' ? dangerClasses : defaultClasses,
24
+ )
25
+
26
+ // Memoize final classes to avoid string concatenation on each render
24
27
  const classes = computed(() => `${baseClasses} ${colorClasses.value}`)
28
+
29
+ // Debounce click handler to prevent rapid clicks
30
+ const debouncedClick = props.handleClick
31
+ ? useDebounceFn(props.handleClick, 150)
32
+ : undefined
25
33
  </script>
26
34
 
27
35
  <template>
@@ -30,7 +38,7 @@ const classes = computed(() => `${baseClasses} ${colorClasses.value}`)
30
38
  :class="classes"
31
39
  role="menuitem"
32
40
  type="button"
33
- @click.prevent="props.handleClick"
41
+ @click.prevent="debouncedClick || props.handleClick"
34
42
  >
35
43
  <slot />
36
44
  </button>
@@ -9,7 +9,8 @@ import {
9
9
  InformationCircleIcon,
10
10
  XMarkIcon,
11
11
  } from '@heroicons/vue/24/solid'
12
- import { computed, h, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
12
+ import { useDebounceFn, useEventListener, useFullscreen, useResizeObserver } from '@vueuse/core'
13
+ import { computed, h, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue'
13
14
  import { useEventBus } from '../../composables/event-bus'
14
15
  import DefaultPaging from './DefaultPaging.vue'
15
16
 
@@ -21,6 +22,21 @@ const isFullscreen = ref<boolean>(false)
21
22
  const infoPanel = ref<boolean>(true) // Show info panel by default
22
23
  const touchStartTime = ref<number>(0)
23
24
  const infoHeight = ref<number>(0)
25
+ const galleryContainerRef = shallowRef<HTMLElement | null>(null)
26
+
27
+ // Use VueUse's useFullscreen for better fullscreen handling
28
+ const { isFullscreen: isElementFullscreen, enter: enterFullscreen, exit: exitFullscreen } = useFullscreen(galleryContainerRef)
29
+
30
+ // Track when fullscreen changes externally (like Escape key)
31
+ watch(isElementFullscreen, (newValue) => {
32
+ isFullscreen.value = newValue
33
+ if (newValue) {
34
+ // Force update of image size when entering fullscreen
35
+ nextTick(() => {
36
+ updateImageSizes()
37
+ })
38
+ }
39
+ })
24
40
 
25
41
  const props = withDefaults(
26
42
  defineProps<{
@@ -72,14 +88,38 @@ const modelValue = computed({
72
88
  const direction = ref<'next' | 'prev'>('next')
73
89
 
74
90
  let controlsTimeout: number | null = null
91
+ let fullscreenResizeTimeout: number | null = null
92
+
93
+ // Used to maintain consistent image sizes
94
+ function updateImageSizes() {
95
+ nextTick(() => {
96
+ const imageContainers = document.querySelectorAll('.image-container img, .image-container .video-component') as NodeListOf<HTMLElement>
97
+ if (imageContainers && imageContainers.length > 0) {
98
+ imageContainers.forEach((img) => {
99
+ // Force a reflow to ensure correct sizing
100
+ img.style.maxHeight = ''
101
+ // Force browser to recalculate styles
102
+ void img.offsetHeight
103
+
104
+ // Calculate the correct height based on fullscreen and info panel state
105
+ const topHeight = 4 // rem
106
+ const infoHeight = infoPanel.value ? Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--info-height') || '0px') / 16 : 0
107
+ const viewHeight = isFullscreen.value ? 90 : 80
108
+
109
+ // Set explicit height with important to override any other styles
110
+ img.style.maxHeight = `calc(${viewHeight}vh - ${infoHeight}rem - ${topHeight}rem) !important`
111
+ })
112
+ }
113
+ })
114
+ }
75
115
 
76
116
  function setModal(value: boolean) {
77
117
  if (value === true) {
78
118
  if (props.onOpen) props.onOpen()
79
119
  document.body.style.overflow = 'hidden' // Prevent scrolling when gallery is open
80
120
  if (!import.meta.env.SSR) {
81
- document.addEventListener('keydown', handleKeyboardInput)
82
- document.addEventListener('keyup', handleKeyboardRelease)
121
+ useEventListener(document, 'keydown', handleKeyboardInput)
122
+ useEventListener(document, 'keyup', handleKeyboardRelease)
83
123
  }
84
124
  // Auto-hide controls after 3 seconds on mobile
85
125
  if (window.innerWidth < 1024) {
@@ -91,27 +131,23 @@ function setModal(value: boolean) {
91
131
  else {
92
132
  if (props.onClose) props.onClose()
93
133
  document.body.style.overflow = '' // Restore scrolling
94
- if (!import.meta.env.SSR) {
95
- document.removeEventListener('keydown', handleKeyboardInput)
96
- document.removeEventListener('keyup', handleKeyboardRelease)
134
+ // Exit fullscreen if active
135
+ if (isFullscreen.value) {
136
+ exitFullscreen()
137
+ isFullscreen.value = false
97
138
  }
98
139
  // Clear timeout if modal is closed
99
140
  if (controlsTimeout) {
100
141
  clearTimeout(controlsTimeout)
101
142
  controlsTimeout = null
102
143
  }
103
- // Exit fullscreen if active
104
- if (isFullscreen.value && document.exitFullscreen) {
105
- document.exitFullscreen().catch(() => {})
106
- isFullscreen.value = false
107
- }
108
144
  }
109
145
  isGalleryOpen.value = value
110
146
  showControls.value = true
111
147
  // Don't reset info panel state when opening/closing
112
148
  }
113
149
 
114
- function openGalleryImage(index: number | undefined) {
150
+ const openGalleryImage = useDebounceFn((index: number | undefined) => {
115
151
  if (index === undefined) {
116
152
  modelValue.value = 0
117
153
  }
@@ -119,7 +155,7 @@ function openGalleryImage(index: number | undefined) {
119
155
  modelValue.value = Number.parseInt(index.toString())
120
156
  }
121
157
  setModal(true)
122
- }
158
+ }, 50) // Debounce to prevent accidental double-opens
123
159
 
124
160
  function goNextImage() {
125
161
  direction.value = 'next'
@@ -200,24 +236,31 @@ function toggleInfoPanel() {
200
236
 
201
237
  function toggleFullscreen() {
202
238
  if (!isFullscreen.value) {
203
- const element = document.querySelector('.gallery-container') as HTMLElement
204
- if (element && element.requestFullscreen) {
205
- element.requestFullscreen().then(() => {
206
- isFullscreen.value = true
207
- }).catch(() => {})
239
+ if (galleryContainerRef.value) {
240
+ enterFullscreen()
241
+ .then(() => {
242
+ isFullscreen.value = true
243
+ // Give browser time to adjust fullscreen before updating sizing
244
+ if (fullscreenResizeTimeout) clearTimeout(fullscreenResizeTimeout)
245
+ fullscreenResizeTimeout = window.setTimeout(() => {
246
+ updateImageSizes()
247
+ }, 50)
248
+ })
249
+ .catch(() => {})
208
250
  }
209
251
  }
210
252
  else {
211
- if (document.exitFullscreen) {
212
- document.exitFullscreen().then(() => {
253
+ exitFullscreen()
254
+ .then(() => {
213
255
  isFullscreen.value = false
214
- }).catch(() => {})
215
- }
256
+ })
257
+ .catch(() => {})
216
258
  }
217
259
  resetControlsTimer()
218
260
  }
219
261
 
220
- function touchStart(event: TouchEvent) {
262
+ // Touch handling with debounce to prevent multiple rapid changes
263
+ const touchStart = useDebounceFn((event: TouchEvent) => {
221
264
  const touch = event.touches[0]
222
265
  const targetElement = touch.target as HTMLElement
223
266
 
@@ -231,9 +274,9 @@ function touchStart(event: TouchEvent) {
231
274
 
232
275
  start.x = touch.screenX
233
276
  start.y = touch.screenY
234
- }
277
+ }, 50)
235
278
 
236
- function touchEnd(event: TouchEvent) {
279
+ const touchEnd = useDebounceFn((event: TouchEvent) => {
237
280
  const touch = event.changedTouches[0]
238
281
  const targetElement = touch.target as HTMLElement
239
282
  const touchDuration = Date.now() - touchStartTime.value
@@ -265,7 +308,7 @@ function touchEnd(event: TouchEvent) {
265
308
  goPrevImage()
266
309
  }
267
310
  }
268
- }
311
+ }, 50)
269
312
 
270
313
  function getBorderColor(i: any) {
271
314
  if (props.borderColor !== undefined) {
@@ -316,18 +359,23 @@ function closeGallery() {
316
359
  setModal(false)
317
360
  }
318
361
 
319
- // Click outside gallery content to close
320
- function handleBackdropClick(event: MouseEvent) {
362
+ // Click outside gallery content to close - with debounce to prevent accidental closes
363
+ const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
321
364
  if (event.target === event.currentTarget) {
322
365
  setModal(false)
323
366
  }
324
- }
367
+ }, 200)
325
368
 
326
- watch(currentImage, () => {
327
- // Only update the info height, don't change user's preference
369
+ // Watch for both image changes and fullscreen mode changes
370
+ watch([currentImage, isFullscreen], () => {
371
+ // Update the info height when image changes or fullscreen state changes
328
372
  if (infoPanel.value) {
329
373
  nextTick(() => {
330
374
  updateInfoHeight()
375
+ // Fix image sizing issue when navigating in fullscreen
376
+ if (isFullscreen.value) {
377
+ updateImageSizes()
378
+ }
331
379
  })
332
380
  }
333
381
  })
@@ -344,42 +392,75 @@ function updateInfoHeight() {
344
392
  })
345
393
  }
346
394
 
395
+ // Debounced window resize handler to avoid performance issues
396
+ const handleWindowResize = useDebounceFn(() => {
397
+ if (isGalleryOpen.value) {
398
+ updateImageSizes()
399
+ updateInfoHeight()
400
+ }
401
+ }, 100)
402
+
347
403
  onMounted(() => {
348
404
  eventBus.on(`${props.id}GalleryImage`, openGalleryImage)
349
405
  eventBus.on(`${props.id}Gallery`, openGalleryImage)
350
406
  eventBus.on(`${props.id}GalleryClose`, closeGallery)
351
407
 
408
+ // Store reference to the gallery container
409
+ galleryContainerRef.value = document.querySelector('.gallery-container')
410
+
352
411
  // Initialize info height once mounted (only if info panel is shown)
353
412
  if (infoPanel.value) {
354
413
  updateInfoHeight()
355
414
  }
356
415
 
357
- // Set up a resize observer to track info panel height changes
358
- const resizeObserver = new ResizeObserver(() => {
359
- if (infoPanel.value) {
360
- updateInfoHeight()
361
- }
362
- })
363
-
416
+ // Use vueUse's useResizeObserver instead of native ResizeObserver
364
417
  const infoElement = document.querySelector('.info-panel-slot')
365
418
  if (infoElement) {
366
- resizeObserver.observe(infoElement)
419
+ useResizeObserver(infoElement as HTMLElement, () => {
420
+ if (infoPanel.value) {
421
+ updateInfoHeight()
422
+ }
423
+ })
367
424
  }
425
+
426
+ // Listen for window resize events
427
+ useEventListener(window, 'resize', handleWindowResize)
428
+
429
+ // Listen for fullscreen changes to update image sizes
430
+ useEventListener(document, 'fullscreenchange', () => {
431
+ if (document.fullscreenElement) {
432
+ // This handles the case of using F11 or browser fullscreen controls
433
+ isFullscreen.value = true
434
+ updateImageSizes()
435
+ }
436
+ else {
437
+ isFullscreen.value = false
438
+ }
439
+ })
368
440
  })
369
441
 
370
442
  onUnmounted(() => {
371
443
  eventBus.off(`${props.id}Gallery`, openGalleryImage)
372
444
  eventBus.off(`${props.id}GalleryImage`, openGalleryImage)
373
445
  eventBus.off(`${props.id}GalleryClose`, closeGallery)
446
+
374
447
  if (!import.meta.env.SSR) {
375
- document.removeEventListener('keydown', handleKeyboardInput)
376
- document.removeEventListener('keyup', handleKeyboardRelease)
377
448
  document.body.style.overflow = '' // Ensure body scrolling is restored
378
449
  }
450
+
379
451
  // Clear any remaining timeouts
380
452
  if (controlsTimeout) {
381
453
  clearTimeout(controlsTimeout)
382
454
  }
455
+
456
+ if (fullscreenResizeTimeout) {
457
+ clearTimeout(fullscreenResizeTimeout)
458
+ }
459
+
460
+ // Ensure we exit fullscreen mode on unmount
461
+ if (isFullscreen.value) {
462
+ exitFullscreen().catch(() => {})
463
+ }
383
464
  })
384
465
  </script>
385
466
 
@@ -441,6 +522,7 @@ onUnmounted(() => {
441
522
  <!-- Main Image Container -->
442
523
  <div
443
524
  class="flex-1 flex flex-col z-[2] items-center justify-center max-w-full lg:max-w-[calc(100vw - 256px)] relative"
525
+ style="padding-top: 1rem;"
444
526
  >
445
527
  <transition
446
528
  :name="direction === 'next' ? 'slide-next' : 'slide-prev'"
@@ -451,7 +533,7 @@ onUnmounted(() => {
451
533
  class="flex-1 w-full max-w-full flex flex-col items-center justify-center absolute inset-0 z-[2]"
452
534
  >
453
535
  <div
454
- class="flex-1 w-full max-w-full flex items-center justify-center"
536
+ class="flex-1 w-full max-w-full flex items-center justify-center image-container"
455
537
  >
456
538
  <template
457
539
  v-if="videoComponent && isVideo(images[modelValue])"
@@ -460,9 +542,15 @@ onUnmounted(() => {
460
542
  <component
461
543
  :is="videoComponent"
462
544
  :src="isVideo(images[modelValue])"
463
- class="shadow max-w-full h-auto object-contain"
545
+ class="shadow max-w-full h-auto object-contain video-component"
464
546
  :style="{
465
- maxHeight: infoPanel ? 'calc(80vh - var(--info-height, 0px) - 4rem)' : 'calc(80vh - 4rem)',
547
+ maxHeight: isFullscreen
548
+ ? infoPanel
549
+ ? 'calc(90vh - var(--info-height, 0px) - 4rem)'
550
+ : 'calc(90vh - 4rem)'
551
+ : infoPanel
552
+ ? 'calc(80vh - var(--info-height, 0px) - 4rem)'
553
+ : 'calc(80vh - 4rem)',
466
554
  }"
467
555
  />
468
556
  </ClientOnly>
@@ -472,7 +560,13 @@ onUnmounted(() => {
472
560
  v-if="modelValueSrc && imageComponent === 'img'"
473
561
  class="shadow max-w-full h-auto object-contain"
474
562
  :style="{
475
- maxHeight: infoPanel ? 'calc(80vh - var(--info-height, 0px) - 4rem)' : 'calc(80vh - 4rem)',
563
+ maxHeight: isFullscreen
564
+ ? infoPanel
565
+ ? 'calc(90vh - var(--info-height, 0px) - 4rem)'
566
+ : 'calc(90vh - 4rem)'
567
+ : infoPanel
568
+ ? 'calc(80vh - var(--info-height, 0px) - 4rem)'
569
+ : 'calc(80vh - 4rem)',
476
570
  }"
477
571
  :src="modelValueSrc"
478
572
  :alt="`Gallery image ${modelValue + 1}`"
@@ -485,7 +579,13 @@ onUnmounted(() => {
485
579
  :alt="modelValueSrc.alt"
486
580
  class="shadow max-w-full h-auto object-contain"
487
581
  :style="{
488
- maxHeight: infoPanel ? 'calc(80vh - var(--info-height, 0px) - 4rem)' : 'calc(80vh - 4rem)',
582
+ maxHeight: isFullscreen
583
+ ? infoPanel
584
+ ? 'calc(90vh - var(--info-height, 0px) - 4rem)'
585
+ : 'calc(90vh - 4rem)'
586
+ : infoPanel
587
+ ? 'calc(80vh - var(--info-height, 0px) - 4rem)'
588
+ : 'calc(80vh - 4rem)',
489
589
  }"
490
590
  />
491
591
  </template>
@@ -539,7 +639,8 @@ onUnmounted(() => {
539
639
  >
540
640
  <div
541
641
  v-if="sidePanel"
542
- class="hidden lg:block flex-shrink-0 w-64 bg-fv-neutral-800/90 backdrop-blur-md h-full max-h-full overflow-y-auto pt-16"
642
+ class="hidden lg:block flex-shrink-0 w-64 bg-fv-neutral-800/90 backdrop-blur-md h-full max-h-full overflow-y-auto"
643
+ style="padding-top: 4rem;"
543
644
  >
544
645
  <!-- Paging Controls -->
545
646
  <div v-if="paging" class="flex items-center justify-center pt-2">
@@ -606,7 +707,7 @@ onUnmounted(() => {
606
707
  >
607
708
  <div
608
709
  v-if="showControls"
609
- class="absolute top-0 left-0 right-0 px-4 py-3 flex justify-between items-center bg-gradient-to-b from-fv-neutral-900/90 to-transparent backdrop-blur-sm z-[50] transition-opacity h-16"
710
+ class="fixed top-0 left-0 right-0 px-4 py-3 flex justify-between items-center bg-gradient-to-b from-fv-neutral-900/90 to-transparent backdrop-blur-sm z-[50] transition-opacity h-16"
610
711
  >
611
712
  <!-- Title and Counter -->
612
713
  <div class="flex items-center space-x-2">
@@ -797,6 +898,15 @@ onUnmounted(() => {
797
898
  </template>
798
899
 
799
900
  <style scoped>
901
+ /* Fix for top controls to ensure they're always visible */
902
+ .fixed.top-0 {
903
+ position: fixed !important;
904
+ top: 0 !important;
905
+ left: 0 !important;
906
+ right: 0 !important;
907
+ z-index: 51 !important;
908
+ }
909
+
800
910
  /* Transition styles for next (right) navigation */
801
911
  .slide-next-enter-active,
802
912
  .slide-next-leave-active {
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { ErrorObject } from '@vuelidate/core'
3
- import { computed, ref, toRef } from 'vue'
3
+ import { useDebounceFn } from '@vueuse/core'
4
+ import { computed, shallowRef, toRef } from 'vue'
4
5
  import { useTranslation } from '../../composables/translations'
5
6
  import DefaultTagInput from './DefaultTagInput.vue'
6
7
 
@@ -53,11 +54,13 @@ const props = withDefaults(
53
54
  )
54
55
 
55
56
  const translate = useTranslation()
56
- const inputRef = ref<HTMLInputElement>()
57
+ // Use shallowRef for DOM elements to reduce reactivity overhead
58
+ const inputRef = shallowRef<HTMLInputElement>()
57
59
 
58
60
  const errorProps = toRef(props, 'error')
59
61
  const errorVuelidateProps = toRef(props, 'errorVuelidate')
60
62
 
63
+ // Memoized error calculation
61
64
  const checkErrors = computed(() => {
62
65
  if (errorProps.value) return errorProps.value
63
66
  if (errorVuelidateProps.value && errorVuelidateProps.value.length > 0) {
@@ -84,20 +87,23 @@ const emit = defineEmits([
84
87
  'blur',
85
88
  ])
86
89
 
87
- function handleFocus() {
90
+ // Debounced event handlers to reduce CPU usage from rapid events
91
+ const handleFocus = useDebounceFn(() => {
88
92
  emit('focus', props.id)
89
- }
90
- function handleBlur() {
93
+ }, 50)
94
+
95
+ const handleBlur = useDebounceFn(() => {
91
96
  emit('blur', props.id)
92
- }
97
+ }, 50)
93
98
 
94
- // Copy input value to clipboard
95
- function copyToClipboard() {
99
+ // Copy input value to clipboard with debounce
100
+ const copyToClipboard = useDebounceFn(() => {
96
101
  if (props.modelValue) {
97
102
  navigator.clipboard.writeText(props.modelValue.toString())
98
103
  }
99
- }
104
+ }, 200)
100
105
 
106
+ // Optimized computed properties with explicit types
101
107
  const model = computed<modelValueType>({
102
108
  get: () => props.modelValue,
103
109
  set: (value) => {