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 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-01-15 13:54:36 +01:00
parent dfb8d1038b
commit 6b80e242b8
2 changed files with 224 additions and 168 deletions

View file

@ -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;
}
</script>
@ -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;

View file

@ -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 }
);
</script>
<style>