import { computed, nextTick, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'; import configRepository from '../../../services/config.js'; /** * * @param value * @param min * @param max */ function clamp(value, min, max) { if (Number.isNaN(value)) return min; return Math.min(max, Math.max(min, value)); } /** * @param options */ export function useAvatarCardGrid(options = {}) { const scaleSlider = { min: options.scaleMin ?? 0.3, max: options.scaleMax ?? 0.9, step: options.scaleStep ?? 0.01 }; const spacingSlider = { min: options.spacingMin ?? 0.5, max: options.spacingMax ?? 1.5, step: options.spacingStep ?? 0.05 }; const baseCardWidth = options.baseCardWidth ?? 200; const baseGap = options.baseGap ?? 12; const baseCardHeight = options.baseCardHeight ?? 200; const scaleConfigKey = options.scaleConfigKey ?? 'VRCX_MyAvatarsCardScale'; const spacingConfigKey = options.spacingConfigKey ?? 'VRCX_MyAvatarsCardSpacing'; const cardScaleBase = ref(0.6); const cardSpacingBase = ref(1); const gridContainerRef = ref(null); const containerWidth = ref(0); let resizeObserver; let cleanupResize; const updateContainerWidth = (el) => { const element = el ?? gridContainerRef.value; if (!element) { containerWidth.value = 0; return; } containerWidth.value = Math.max( element.clientWidth ?? element.offsetWidth ?? 0, 0 ); }; const disconnectResize = () => { if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = undefined; } if (cleanupResize) { cleanupResize(); cleanupResize = undefined; } }; const cardScale = computed({ get: () => cardScaleBase.value, set: (value) => { const next = clamp( Number(value) || 1, scaleSlider.min, scaleSlider.max ); cardScaleBase.value = next; configRepository.setString(scaleConfigKey, String(next)); } }); const cardSpacing = computed({ get: () => cardSpacingBase.value, set: (value) => { const next = clamp( Number(value) || 1, spacingSlider.min, spacingSlider.max ); cardSpacingBase.value = next; configRepository.setString(spacingConfigKey, String(next)); } }); const cardScalePercent = computed(() => Math.round(cardScale.value * 100)); const cardSpacingPercent = computed(() => Math.round(cardSpacing.value * 100) ); // Slider v-model helpers (shadcn Slider expects array) const cardScaleValue = computed({ get: () => [cardScale.value], set: (value) => { const next = value?.[0]; if (typeof next === 'number') cardScale.value = next; } }); const cardSpacingValue = computed({ get: () => [cardSpacing.value], set: (value) => { const next = value?.[0]; if (typeof next === 'number') cardSpacing.value = next; } }); /** * @param count */ const getGridMetrics = (count = 1) => { const scale = cardScale.value; const spacing = cardSpacing.value; const minWidth = baseCardWidth * scale; const gap = Math.max(4, baseGap * spacing); const width = Math.max(containerWidth.value, 0); const itemCount = Math.max(Number(count) || 0, 0); const safeCount = itemCount > 0 ? itemCount : 1; const maxColumns = width > 0 ? Math.max(1, Math.floor((width + gap) / (minWidth + gap)) || 1) : 1; const columns = Math.max(1, Math.min(safeCount, maxColumns)); // Stretch cards to fill available width let cardWidth = minWidth; if (itemCount >= maxColumns && columns > 0) { const columnsWidth = width - gap * (columns - 1); const rawWidth = columnsWidth > 0 ? columnsWidth / columns : minWidth; if (Number.isFinite(rawWidth) && rawWidth > 0) { cardWidth = Math.max(minWidth, rawWidth); } } return { minWidth, gap, columns, cardWidth }; }; /** */ const gridStyle = computed(() => { const scale = cardScale.value; const spacing = cardSpacing.value; const minWidth = baseCardWidth * scale; const gap = Math.max(4, baseGap * spacing); return (count = 1) => { const { columns, cardWidth } = getGridMetrics(count); return { '--avatar-card-min-width': `${Math.round(minWidth)}px`, '--avatar-card-gap': `${Math.round(gap)}px`, '--avatar-card-target-width': `${Math.round(cardWidth)}px`, '--avatar-grid-columns': `${columns}` }; }; }); /** * @param items * @param keyPrefix */ const chunkIntoRows = (items, keyPrefix = 'row') => { if (!Array.isArray(items) || !items.length) return []; const { columns } = getGridMetrics(items.length); const safeColumns = Math.max(1, columns); const rows = []; for (let i = 0; i < items.length; i += safeColumns) { rows.push({ key: `${keyPrefix}:${i}`, items: items.slice(i, i + safeColumns) }); } return rows; }; /** * @param itemCount */ const estimateRowHeight = (itemCount = 0) => { const scale = cardScale.value; const spacing = cardSpacing.value; const { columns, gap } = getGridMetrics(itemCount); const safeColumns = Math.max(1, columns); const rowCount = Math.max(1, Math.ceil(itemCount / safeColumns)); // Card height = image (aspect 4:3 of width) + name area const cardHeight = baseCardHeight * scale * spacing; return rowCount * cardHeight + (rowCount - 1) * gap + 4; }; // Watch container ref for resize watch( gridContainerRef, (element) => { disconnectResize(); if (!element) { containerWidth.value = 0; return; } nextTick(() => updateContainerWidth(element)); if (typeof ResizeObserver !== 'undefined') { resizeObserver = new ResizeObserver((entries) => { if (!entries?.length) return; const [entry] = entries; containerWidth.value = Math.max( entry.contentRect?.width ?? element.clientWidth ?? 0, 0 ); }); resizeObserver.observe(element); return; } if (typeof window !== 'undefined') { const handleResize = () => updateContainerWidth(element); window.addEventListener('resize', handleResize, { passive: true }); cleanupResize = () => window.removeEventListener('resize', handleResize); } }, { immediate: true } ); onBeforeUnmount(() => disconnectResize()); onBeforeMount(async () => { try { const [storedScale, storedSpacing] = await Promise.all([ configRepository.getString(scaleConfigKey, '0.6'), configRepository.getString(spacingConfigKey, '1') ]); const parsedScale = parseFloat(storedScale); if (!Number.isNaN(parsedScale)) { cardScaleBase.value = clamp( parsedScale, scaleSlider.min, scaleSlider.max ); } const parsedSpacing = parseFloat(storedSpacing); if (!Number.isNaN(parsedSpacing)) { cardSpacingBase.value = clamp( parsedSpacing, spacingSlider.min, spacingSlider.max ); } } catch (error) { console.error('Failed to load avatar card grid preferences', error); } }); return { cardScale, cardSpacing, cardScalePercent, cardSpacingPercent, cardScaleValue, cardSpacingValue, scaleSlider, spacingSlider, gridContainerRef, gridStyle, getGridMetrics, chunkIntoRows, estimateRowHeight, updateContainerWidth }; }