From 6b80e242b87009c2eaeaafffa2b2e21a295f8a0e Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 13:54:36 +0100 Subject: [PATCH] Fix virtual sample routing and refactor for clarity Virtual sample variations now display correctly when loading from URL hash. Old URLs with underscores are normalized to hyphens on load. URL hash updates automatically when navigating between variations. Refactored both DynamicView and Selector components with explicit function names, removed unnecessary comments, and improved code organization. Co-Authored-By: Claude Sonnet 4.5 --- src/components/Selector.vue | 232 ++++++++++-------- .../project/virtual-sample/DynamicView.vue | 160 +++++++----- 2 files changed, 224 insertions(+), 168 deletions(-) diff --git a/src/components/Selector.vue b/src/components/Selector.vue index 32521cb..c95299d 100644 --- a/src/components/Selector.vue +++ b/src/components/Selector.vue @@ -76,112 +76,149 @@ const { items, label, isCompareModeEnabled, index } = defineProps({ // Local state const currentValue = ref(null); -const syncing = ref(false); // empêche les réactions pendant les mises à jour programmatiques +const syncing = ref(false); -// Store const { activeTracks } = storeToRefs(useDialogStore()); -// Utils -function isSame(a, b) { - if (!a || !b) return false; - if (a.slug && b.slug) return a.slug === b.slug; - return a.title === b.title; +function normalizeSlug(slug) { + return slug.replace(/_/g, '-'); } -function toVariation(v) { - if (!v) return null; - return Array.isArray(v) ? v[v.length - 1] || null : v; +function areVariationsEqual(variationA, variationB) { + if (!variationA || !variationB) return false; + + if (variationA.slug && variationB.slug) { + return normalizeSlug(variationA.slug) === normalizeSlug(variationB.slug); + } + + return variationA.title === variationB.title; } -// Initialisation : remplir le 1er select localement ET initialiser le store -onBeforeMount(() => { +function extractVariation(value) { + if (!value) return null; + return Array.isArray(value) ? value[value.length - 1] || null : value; +} + +function convertValueForCompareMode(value, shouldBeArray) { + if (shouldBeArray) { + return value && !Array.isArray(value) ? [value] : value; + } else { + return Array.isArray(value) ? value[0] || null : value; + } +} + +function findMatchingVariationsInStore(storeVariations) { + return storeVariations.filter((storeVar) => + items.some((item) => areVariationsEqual(item, storeVar)) + ); +} + +function syncCurrentValueFromStore(storeVariations) { syncing.value = true; - if (index === 0) { - currentValue.value = items[0] || null; - // si le store est vide, initialiser avec la variation du premier sélecteur - if (!activeTracks.value || activeTracks.value.length === 0) { - const v = toVariation(items[0]); - if (v) activeTracks.value = [v]; - } + const matchedVariations = findMatchingVariationsInStore(storeVariations); + + if (isCompareModeEnabled) { + currentValue.value = matchedVariations.length ? [...matchedVariations] : []; } else { - // les autres ne forcent pas le store ; leur currentValue restera à null - currentValue.value = null; + currentValue.value = matchedVariations[0] || null; } nextTick(() => (syncing.value = false)); -}); +} + +function detectVariationChanges(newValues, oldValues) { + const newList = Array.isArray(newValues) + ? newValues + : newValues + ? [newValues] + : []; + const oldList = Array.isArray(oldValues) + ? oldValues + : oldValues + ? [oldValues] + : []; + + const addedVariation = newList.find( + (n) => !oldList.some((o) => areVariationsEqual(o, n)) + ); + const removedVariation = oldList.find( + (o) => !newList.some((n) => areVariationsEqual(n, o)) + ); + + return { addedVariation, removedVariation }; +} + +function handleVariationChange(newValue, oldValue) { + if (syncing.value) return; + + const { addedVariation, removedVariation } = detectVariationChanges( + newValue, + oldValue + ); + + if ( + addedVariation && + items.some((item) => areVariationsEqual(item, addedVariation)) + ) { + updateActiveTracks(addedVariation, 'add'); + } else if ( + removedVariation && + items.some((item) => areVariationsEqual(item, removedVariation)) + ) { + updateActiveTracks(removedVariation, 'remove'); + } +} -// Quand on bascule compare mode (objet <-> tableau) watch( () => isCompareModeEnabled, - (flag) => { + (shouldBeArray) => { syncing.value = true; - if (flag) { - if (currentValue.value && !Array.isArray(currentValue.value)) { - currentValue.value = [currentValue.value]; - } - } else { - if (Array.isArray(currentValue.value)) { - currentValue.value = currentValue.value[0] || null; - } - } + currentValue.value = convertValueForCompareMode( + currentValue.value, + shouldBeArray + ); nextTick(() => (syncing.value = false)); } ); -// Détection ajout / suppression dans le MultiSelect (côté composant) -// On n'agit que si l'ajout/suppression concerne une variation appartenant à `items` -watch( - currentValue, - (newVal, oldVal) => { - if (syncing.value) return; +watch(currentValue, handleVariationChange, { deep: true }); - const newItems = Array.isArray(newVal) ? newVal : newVal ? [newVal] : []; - const oldItems = Array.isArray(oldVal) ? oldVal : oldVal ? [oldVal] : []; - - const added = newItems.find((n) => !oldItems.some((o) => isSame(o, n))); - const removed = oldItems.find((o) => !newItems.some((n) => isSame(n, o))); - - if (added && items.some((it) => isSame(it, added))) { - selectTrack(added, 'add'); - } else if (removed && items.some((it) => isSame(it, removed))) { - selectTrack(removed, 'remove'); - } - }, - { deep: true } -); - -// Quand activeTracks change elsewhere -> synchroniser l'affichage local -// Mais n'adopter que les variations qui appartiennent à ce Selector (`items`) watch( activeTracks, - (newVal) => { - syncing.value = true; - - const storeList = Array.isArray(newVal) ? newVal : []; - // ne garder que les variations du store qui sont dans `items` - const matched = storeList.filter((av) => - items.some((it) => isSame(it, av)) - ); - - if (isCompareModeEnabled) { - currentValue.value = matched.length ? [...matched] : []; - } else { - currentValue.value = matched[0] || null; - } - - nextTick(() => (syncing.value = false)); + (storeVariations) => { + const variationsList = Array.isArray(storeVariations) + ? storeVariations + : []; + syncCurrentValueFromStore(variationsList); }, - { deep: true } + { deep: true, immediate: true } ); -// Logique centrale de sélection (ajout / suppression) -// Règles : -// - mode normal -> activeTracks = [variation] -// - mode comparaison -> conserver activeTracks[0] si possible; second élément ajouté/remplacé; suppression gère le cas de la suppression de la première -function selectTrack(track, action = 'add') { - const variation = toVariation(track); +function removeVariationFromActiveTracks(variation) { + activeTracks.value = activeTracks.value.filter( + (track) => !areVariationsEqual(track, variation) + ); +} + +function addVariationToActiveTracks(variation) { + const isAlreadyPresent = activeTracks.value.some((track) => + areVariationsEqual(track, variation) + ); + + if (isAlreadyPresent) return; + + if (activeTracks.value.length === 0) { + activeTracks.value = [variation]; + } else if (activeTracks.value.length === 1) { + activeTracks.value = [activeTracks.value[0], variation]; + } else { + activeTracks.value = [activeTracks.value[0], variation]; + } +} + +function updateActiveTracks(track, action = 'add') { + const variation = extractVariation(track); if (!variation) return; if (!isCompareModeEnabled) { @@ -190,34 +227,12 @@ function selectTrack(track, action = 'add') { } if (action === 'remove') { - const wasFirst = - activeTracks.value.length && isSame(activeTracks.value[0], variation); - activeTracks.value = activeTracks.value.filter( - (t) => !isSame(t, variation) - ); - - // si on a retiré la première et qu'il reste une piste, elle devient naturellement index 0 - // pas d'action supplémentaire nécessaire ici (déjà assuré par le filter) - return; - } - - // action === 'add' - if (activeTracks.value.some((t) => isSame(t, variation))) { - // déjà présent -> ignore - return; - } - - if (activeTracks.value.length === 0) { - activeTracks.value = [variation]; - } else if (activeTracks.value.length === 1) { - activeTracks.value = [activeTracks.value[0], variation]; + removeVariationFromActiveTracks(variation); } else { - // remplacer le 2e - activeTracks.value = [activeTracks.value[0], variation]; + addVariationToActiveTracks(variation); } } -// Helpers pour affichage (inchangés) function getFrontViewUrl(item) { if (!item) return ''; if (Array.isArray(item)) { @@ -231,8 +246,8 @@ function getFrontViewUrl(item) { } function setImage() { - return getFrontViewUrl(currentValue.value) - ? '--image: url(\'' + getFrontViewUrl(currentValue.value) + '\')' + return getFrontViewUrl(currentValue.value) + ? "--image: url('" + getFrontViewUrl(currentValue.value) + "')" : undefined; } @@ -250,7 +265,8 @@ function setImage() { padding: var(--space-8) var(--space-48) var(--space-8) var(--space-16); } .selector-dropdown.has-image, -.selector-dropdown.has-image :is(#selector-select, #selector-multiselect, [role='combobox']) { +.selector-dropdown.has-image + :is(#selector-select, #selector-multiselect, [role='combobox']) { padding-left: var(--space-64); } .selector-dropdown.has-image:before { @@ -290,7 +306,9 @@ function setImage() { cursor: pointer; } [role='combobox'] p, -.selector-dropdown [data-pc-section="labelcontainer"] > [data-pc-section='label'] { +.selector-dropdown + [data-pc-section='labelcontainer'] + > [data-pc-section='label'] { max-height: 1lh; overflow: hidden; text-overflow: ellipsis; diff --git a/src/components/project/virtual-sample/DynamicView.vue b/src/components/project/virtual-sample/DynamicView.vue index 8273c73..60bcd8e 100644 --- a/src/components/project/virtual-sample/DynamicView.vue +++ b/src/components/project/virtual-sample/DynamicView.vue @@ -61,13 +61,14 @@ import { storeToRefs } from 'pinia'; import { usePageStore } from '../../../stores/page'; import { useDialogStore } from '../../../stores/dialog'; import { useVirtualSampleStore } from '../../../stores/virtualSample'; -import { useRoute } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router'; import Interactive360 from './Interactive360.vue'; import SingleImage from './SingleImage.vue'; import Selector from '../../Selector.vue'; import slugify from 'slugify'; const route = useRoute(); +const router = useRouter(); const { page } = storeToRefs(usePageStore()); const { isCommentsOpen, isCommentPanelEnabled, activeTracks, openedFile } = @@ -92,51 +93,74 @@ const tracks = computed(() => { return list; }); -// ---------- INITIALISATION ---------- -// onBeforeMount : on initialise toujours activeTracks avec une VARIATION (jamais un track) -onBeforeMount(() => { - // essayer la hash en priorité - let initialVariation = null; +function normalizeSlug(slug) { + return slug.replace(/_/g, '-'); +} +function getVariationSlug(variation) { + return variation.slug || (variation.title ? slugify(variation.title) : null); +} + +function findVariationByHash(hashValue) { + const allVariations = tracks.value.flatMap((track) => track.variations || []); + const normalizedHash = normalizeSlug(hashValue); + + return allVariations.find((variation) => { + const variationSlug = getVariationSlug(variation); + if (!variationSlug) return false; + + const normalizedVariationSlug = normalizeSlug(variationSlug); + return normalizedVariationSlug === normalizedHash; + }); +} + +function getInitialVariation() { if (route?.hash && route.hash.length > 0) { - const variations = tracks.value.flatMap((t) => t.variations || []); const hashValue = route.hash.substring(1); - - // Essayer de trouver la variation soit par slug direct, soit en normalisant le hash - initialVariation = variations.find((v) => { - // Comparaison directe - if (v.slug === hashValue) return true; - // Comparaison en convertissant underscores en tirets (slugify par défaut) - if (v.slug === hashValue.replace(/_/g, '-')) return true; - // Comparaison inverse : le slug du backend pourrait avoir des underscores - if (v.slug.replace(/-/g, '_') === hashValue) return true; - return false; - }) || null; + const variationFromHash = findVariationByHash(hashValue); + if (variationFromHash) return variationFromHash; } - // fallback : première variation du premier track - if (!initialVariation) { - initialVariation = tracks.value[0]?.variations?.[0] || null; - } + return tracks.value[0]?.variations?.[0] || null; +} - if (initialVariation) { - activeTracks.value = [initialVariation]; - } else { - activeTracks.value = []; // aucun contenu disponible - } -}); +function initializeActiveTracks() { + const initialVariation = getInitialVariation(); + activeTracks.value = initialVariation ? [initialVariation] : []; +} -// scroll si hash présent -onMounted(() => { - if (route.query?.comments) isCommentsOpen.value = true; +function normalizeUrlHash() { + if (route?.hash && route.hash.includes('_')) { + const normalizedHash = normalizeSlug(route.hash); + router.replace({ ...route, hash: normalizedHash }); + } +} + +function openCommentsIfRequested() { + if (route.query?.comments) { + isCommentsOpen.value = true; + } +} + +function scrollToHashTarget() { if (!route?.hash || route.hash.length === 0) return; - const selector = route.hash.replace('#', '#track--'); - const targetBtn = document.querySelector(selector); - if (targetBtn) targetBtn.scrollIntoView(); + const selectorId = route.hash.replace('#', '#track--'); + const targetButton = document.querySelector(selectorId); + if (targetButton) { + targetButton.scrollIntoView(); + } +} + +onBeforeMount(() => { + initializeActiveTracks(); }); -// ---------- COMPUTED / WATCH ---------- +onMounted(() => { + openCommentsIfRequested(); + normalizeUrlHash(); + scrollToHashTarget(); +}); const isSingleImage = computed(() => { return ( @@ -149,38 +173,52 @@ const singleFile = computed(() => { return isSingleImage.value ? activeTracks.value[0].files[0] : null; }); -watch( - singleFile, - (newValue) => { - if (newValue) openedFile.value = newValue; - }, - { immediate: true } -); - -// gestion du mode comparaison : fermer les commentaires, etc. -watch(isCompareModeEnabled, (newValue) => { - if (newValue) { - isCommentsOpen.value = false; - isCommentPanelEnabled.value = false; - } else { - isCommentPanelEnabled.value = true; +function updateOpenedFile(file) { + if (file) { + openedFile.value = file; } +} - // quand on quitte le mode comparaison on retire l'élément secondaire si nécessaire - if (!newValue && activeTracks.value.length === 2) { +function enableCompareModeUI() { + isCommentsOpen.value = false; + isCommentPanelEnabled.value = false; +} + +function disableCompareModeUI() { + isCommentPanelEnabled.value = true; + + if (activeTracks.value.length === 2) { activeTracks.value.pop(); } +} + +function updateUrlHash(firstTrack) { + const trackSlug = getVariationSlug(firstTrack); + if (!trackSlug) return; + + const currentHash = route.hash ? route.hash.substring(1) : ''; + const normalizedTrackSlug = normalizeSlug(trackSlug); + + if (currentHash !== normalizedTrackSlug) { + router.replace({ ...route, hash: '#' + normalizedTrackSlug }); + } +} + +watch(singleFile, updateOpenedFile, { immediate: true }); + +watch(isCompareModeEnabled, (isEnabled) => { + isEnabled ? enableCompareModeUI() : disableCompareModeUI(); }); -// ---------- UTIL / helper ---------- -function getCommentsCount(track) { - if (!track || !Array.isArray(track.files)) return undefined; - let count = 0; - for (const file of track.files) { - count += file?.comments?.length || 0; - } - return count > 0 ? count : undefined; -} +watch( + activeTracks, + (tracks) => { + if (tracks && tracks.length > 0) { + updateUrlHash(tracks[0]); + } + }, + { deep: true } +);