FEATURE + REFACTOR Handling sommaire chapter progression + refactoring footnote hebaviour
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Nonimart 2025-09-25 11:59:29 +02:00
parent 36bad45e67
commit 53e280383e
4 changed files with 307 additions and 112 deletions

View File

@ -1,9 +1,84 @@
import {
scrollToFootnoteInIndexPanel,
toggleActiveTabPanel,
toggleActiveFootnoteLinkInIndexPanel,
} from './index-panel';
import { toggleActiveTabPanel } from './index-panel';
/* ********************************************************
* ************* SCROLLING FUNCTIONS *********************
* ********************************************************
*/
export function scrollToFootnoteInIndexPanel(footnoteId: string): void {
const footnotesIndex = document.querySelector('#footnotes-index') as HTMLElement;
const footnotesIndexContainer = document.querySelector('ul#footnotes-index');
const indexPanelContent = document.querySelector('.index-panel__content') as HTMLElement;
const currentFootnote = footnotesIndexContainer?.querySelector(
`a[href="#${footnoteId}"]`
) as HTMLElement;
if (currentFootnote && footnotesIndexContainer) {
const containerRect = footnotesIndexContainer.getBoundingClientRect();
const elementRect = currentFootnote.getBoundingClientRect();
const relativeTop = elementRect.top - containerRect.top;
const scrollTop = footnotesIndexContainer.scrollTop + relativeTop - 20;
footnotesIndexContainer.scrollTo({
top: scrollTop,
behavior: 'smooth',
});
}
}
export function scrollToFootnote(footnoteId: string): void {
const footnote = document.querySelector(`a.footnote-reference#${footnoteId}`);
if (!footnote) return;
footnote.scrollIntoView({ behavior: 'smooth' });
}
/* ********************************************************
* ************* TOGGLE ACTIVE ***************************
* ********************************************************
*/
export function toggleActiveFootnoteLinkInIndexPanel(footnoteId: string): void {
const footnotesIndexLinks = document.querySelectorAll('.footnote-reference-item');
const indexPanel = document.querySelector('.index-panel') as HTMLElement;
const currentFootnote = indexPanel.querySelector(`a[href="#${footnoteId}"]`) as HTMLElement;
footnotesIndexLinks.forEach((footnoteLink) => {
footnoteLink.setAttribute('active', 'false');
});
currentFootnote?.setAttribute('active', 'true');
}
/* ********************************************************
* ************* OBSERVE LINKS ***************************
* ********************************************************
*/
export function observeFootnotesLinks(): void {
const footnotesLinks = document.querySelectorAll('.footnotes-index a');
footnotesLinks.forEach((footnoteLink) => {
footnoteLink.addEventListener('click', (e) => {
e.preventDefault();
const target = e.target as HTMLElement;
const href = target.getAttribute('href');
if (!href) return;
const targetId = href.startsWith('#') ? href.substring(1) : href;
if (!targetId) return;
scrollToFootnote(targetId);
// document.querySelector(`#${targetId}`)?.scrollIntoView({ behavior: 'smooth' });
toggleActiveFootnoteLinkInIndexPanel(targetId);
scrollToFootnoteInIndexPanel(targetId);
});
});
}
/* ********************************************************
* ************* MAIN HANDLE GENERAL BEHAVIOUR *********
* ********************************************************
*/
export default function handleFootnoteFormat(): void {
const footnotes = document.querySelectorAll('.content-area .footnote-reference');
@ -18,10 +93,3 @@ export default function handleFootnoteFormat(): void {
});
});
}
export function scrollToFootnote(footnoteId: string): void {
const footnote = document.querySelector(`a.footnote-reference#${footnoteId}`);
if (!footnote) return;
footnote.scrollIntoView({ behavior: 'smooth' });
}

View File

@ -1,13 +1,19 @@
import { scrollToFootnote } from './footnote-format';
import { handleSmoothScrollToTitle } from './sommaire';
import handleChapterProgression, { observeSommaireLinks } from './sommaire';
import { observeFootnotesLinks } from './footnote-format';
export default function handleIndexPanel(): void {
const indexPanel = document.querySelector('.index-panel');
if (!indexPanel) return;
// TABS
observeTabsButtons();
// FOOTNOTES
observeFootnotesLinks();
// INDEX PANEL MOBILE
handleMobileOpenToggle();
// CHAPITRES
observeSommaireLinks();
handleChapterProgression();
handleMobileOpenToggle();
}
@ -19,7 +25,7 @@ function handleMobileOpenToggle(): void {
mobileOpenToggle.addEventListener('click', () => {
const isMobileOpen = indexPanel.getAttribute('data-mobile-open');
console.log(isMobileOpen);
if (isMobileOpen === 'true') {
indexPanel.setAttribute('data-mobile-open', 'false');
} else {
@ -63,99 +69,3 @@ export function toggleActiveTabPanel(dataIndex: string): void {
activeButton.setAttribute('aria-selected', 'true');
activePanel.setAttribute('aria-hidden', 'false');
}
// ********************************************************
// ************* SOMMAIRE *********************************
// ********************************************************
function observeSommaireLinks(): void {
const sommaireTitles: NodeListOf<Element> = document.querySelectorAll('.sommaire-index li a');
for (const title of sommaireTitles) {
title.addEventListener('click', (e) => {
e.preventDefault();
const href = title.getAttribute('href');
if (!href) return;
const targetId = href.startsWith('#') ? href.substring(1) : href;
if (!targetId) return;
handleSmoothScrollToTitle(targetId);
toggleActiveChapterLinkInIndexPanel(targetId);
});
}
}
function toggleActiveChapterLinkInIndexPanel(targetId: string): void {
const sommaireLinks: NodeListOf<Element> = document.querySelectorAll('.sommaire-index li a');
const indexPanel = document.querySelector('.index-panel') as HTMLElement;
const currentLink = indexPanel.querySelector(`a[href="#${targetId}"]`) as HTMLElement;
if (!currentLink) return;
for (const link of sommaireLinks) {
link.setAttribute('active', 'false');
}
currentLink?.setAttribute('active', 'true');
}
// ********************************************************
// ************* FOOTNOTES *********************************
// ********************************************************
function observeFootnotesLinks(): void {
const footnotesLinks = document.querySelectorAll('.footnotes-index a');
footnotesLinks.forEach((footnoteLink) => {
footnoteLink.addEventListener('click', (e) => {
e.preventDefault();
const target = e.target as HTMLElement;
const href = target.getAttribute('href');
if (!href) return;
const targetId = href.startsWith('#') ? href.substring(1) : href;
if (!targetId) return;
scrollToFootnote(targetId);
// document.querySelector(`#${targetId}`)?.scrollIntoView({ behavior: 'smooth' });
toggleActiveFootnoteLinkInIndexPanel(targetId);
scrollToFootnoteInIndexPanel(targetId);
});
});
}
export function toggleActiveFootnoteLinkInIndexPanel(footnoteId: string): void {
const footnotesIndexLinks = document.querySelectorAll('.footnote-reference-item');
const indexPanel = document.querySelector('.index-panel') as HTMLElement;
const currentFootnote = indexPanel.querySelector(`a[href="#${footnoteId}"]`) as HTMLElement;
footnotesIndexLinks.forEach((footnoteLink) => {
footnoteLink.setAttribute('active', 'false');
});
currentFootnote?.setAttribute('active', 'true');
}
export function scrollToFootnoteInIndexPanel(footnoteId: string): void {
const footnotesIndex = document.querySelector('#footnotes-index') as HTMLElement;
const footnotesIndexContainer = document.querySelector('ul#footnotes-index');
const indexPanelContent = document.querySelector('.index-panel__content') as HTMLElement;
const currentFootnote = footnotesIndexContainer?.querySelector(
`a[href="#${footnoteId}"]`
) as HTMLElement;
if (currentFootnote && footnotesIndexContainer) {
const containerRect = footnotesIndexContainer.getBoundingClientRect();
const elementRect = currentFootnote.getBoundingClientRect();
const relativeTop = elementRect.top - containerRect.top;
const scrollTop = footnotesIndexContainer.scrollTop + relativeTop - 20;
footnotesIndexContainer.scrollTo({
top: scrollTop,
behavior: 'smooth',
});
}
}

View File

@ -3,6 +3,7 @@ import handleFootnoteFormat from './footnote-format';
import handleCiteButton from './cite-button';
import handleLikeButton from './like-button.ts';
import handleShareButton from './share-button.ts';
import { handleArticleToolbar } from './article-toolbar.ts';
import { handleRevueToolbar } from './revue-toolbar.ts';

View File

@ -1,12 +1,228 @@
/* ********************************************************
* ************* SCROLLING FUNCTIONS *********************
* ********************************************************
*/
/**
* Effectue un scroll fluide vers un élément ciblé avec un offset personnalisé.
* @param targetId - ID de l'élément cible (sans le #)
*/
export function handleSmoothScrollToTitle(targetId: string): void {
const targetElement = document.querySelector(`#${targetId}`);
if (!targetElement) return;
const elementRect = targetElement.getBoundingClientRect();
const offset = 30; // 10px offset from top
const offset = 30; // 30px offset from top
window.scrollTo({
top: window.pageYOffset + elementRect.top - offset,
behavior: 'smooth',
});
}
/**
* Fait défiler l'indicateur de position vers le chapitre actif dans l'index panel.
* @param targetId - ID du chapitre cible
*/
export function scrollToActiveChapterInIndexPanel(targetId: string): void {
// const activeLink = document.querySelector(`a[active="true"]`) as HTMLElement;
// const targetPosition = activeLink.offsetTop;
// const targetHeight = activeLink.offsetHeight;
// let chapterIndicator = document.querySelector('.chapter_index__position-indicator');
// chapterIndicator.style.top = targetPosition + 'px';
// chapterIndicator.style.height = targetHeight + 'px';
}
// ********************************************************
// ************* TOGGLE ACTIVE **************************
// ********************************************************
/**
* Désactive tous les liens de chapitre précédemment actifs.
*/
function removePreviousActiveLink() {
const activeLinks = document.querySelectorAll(
'.index-panel__content .sommaire-index li a[active="true"]'
);
activeLinks.forEach((link) => {
link.setAttribute('active', 'false');
});
}
/**
* Active le lien de chapitre correspondant à l'ID cible dans l'index panel.
* @param targetId - ID du chapitre à activer
*/
export function toggleActiveChapterLinkInIndexPanel(targetId: string): void {
const sommaireLinks: NodeListOf<Element> = document.querySelectorAll('.sommaire-index li a');
const indexPanel = document.querySelector('.index-panel') as HTMLElement;
const currentLink = indexPanel.querySelector(`a[href="#${targetId}"]`) as HTMLElement;
if (!currentLink) return;
for (const link of sommaireLinks) {
link.setAttribute('active', 'false');
}
currentLink?.setAttribute('active', 'true');
}
// ********************************************************
// ************* CHAPTER OBSERVER STATE MANAGEMENT *******
// ********************************************************
// Variable globale pour contrôler l'état de pause de l'intersection observer
let isChapterObserverPaused = false;
/**
* Met en pause ou réactive l'intersection observer des chapitres.
* @param paused - État de pause à définir
*/
export function setChapterObserverPaused(paused: boolean): void {
isChapterObserverPaused = paused;
}
/**
* Retourne l'état actuel de pause de l'intersection observer.
* @returns État de pause de l'observer
*/
export function getChapterObserverPausedState(): boolean {
return isChapterObserverPaused;
}
/**
* Ajoute des écouteurs d'événements aux liens de chapitre pour la navigation.
*/
function observeChapterLinks(): void {
let chapterLinks = document.querySelectorAll('.chapter_index__link');
if (!chapterLinks) return;
chapterLinks.forEach((link) => {
link.addEventListener('click', (e) => {
handleLinkScrollToTarget(e);
});
});
}
/**
* Intersection Observer qui détecte automatiquement les chapitres visibles
* et met à jour l'état actif dans l'index panel.
*/
const chapterProgressionObserver = new IntersectionObserver(
(entries) => {
// Ne pas traiter les entrées si l'observer est en pause (pendant un clic)
const isIntersetionObserverPaused = getChapterObserverPausedState();
if (isIntersetionObserverPaused) return;
entries.forEach((entry) => {
const blockId = entry.target.getAttribute('id');
const relatedChapterLink = document.querySelector(`a[href="#${blockId}"]`);
if (entry.isIntersecting) {
removePreviousActiveLink();
entry.target.setAttribute('active', 'true');
relatedChapterLink?.setAttribute('active', 'true');
}
});
},
{
rootMargin: '-10% 0px -50% 0px',
}
);
/**
* Gère le scroll vers un élément cible lors du clic sur un lien.
* @param e - Événement du clic
*/
function handleLinkScrollToTarget(e: Event) {
e.preventDefault();
let target = (e.target as HTMLElement).getAttribute('href');
if (!target) return;
let targetBlock = document.querySelector(target);
if (!targetBlock) return;
targetBlock.setAttribute('tabindex', '-1');
targetBlock.scrollIntoView({
behavior: 'smooth',
});
(targetBlock as HTMLElement).focus({ preventScroll: true });
}
// ********************************************************
// ************* MAIN FUNCTION ****************************
// ********************************************************
/**
* Initialise le système de progression des chapitres avec l'intersection observer.
* Active la détection automatique des chapitres visibles lors du scroll.
*/
export default function handleChapterProgression() {
const hasChapterIndex = document.querySelector('.index-panel__content .sommaire-index');
if (!hasChapterIndex) return;
// Debug function to visualize rootMargin
// function createDebugOverlay() {
// const overlay = document.createElement('div');
// overlay.style.position = 'fixed';
// overlay.style.top = '10%';
// overlay.style.bottom = '50%';
// overlay.style.left = '0';
// overlay.style.right = '0';
// overlay.style.border = '2px solid red';
// overlay.style.pointerEvents = 'none';
// overlay.style.zIndex = '9999';
// overlay.style.opacity = '0.3';
// overlay.style.backgroundColor = 'rgba(255, 0, 0, 0.1)';
// document.body.appendChild(overlay);
// }
// Uncomment the line below to see the detection zone
// createDebugOverlay();
// Initialiser les écouteurs de liens
observeChapterLinks();
// Observer tous les titres h2 de l'article
const titlesBlocks = document.querySelectorAll('.article-content h2');
titlesBlocks.forEach((block) => {
chapterProgressionObserver.observe(block);
});
}
// ********************************************************
// ************* SOMMAIRE NAVIGATION **********************
// ********************************************************
/**
* Ajoute des écouteurs d'événements aux liens du sommaire pour la navigation manuelle.
* Gère le conflit avec l'intersection observer en le pausant temporairement.
*/
export function observeSommaireLinks(): void {
const sommaireTitles: NodeListOf<Element> = document.querySelectorAll('.sommaire-index li a');
for (const title of sommaireTitles) {
title.addEventListener('click', (e) => {
e.preventDefault();
const href = title.getAttribute('href');
if (!href) return;
const targetId = href.startsWith('#') ? href.substring(1) : href;
if (!targetId) return;
// Désactiver temporairement l'intersection observer pour éviter les conflits
setChapterObserverPaused(true);
handleSmoothScrollToTitle(targetId);
toggleActiveChapterLinkInIndexPanel(targetId);
// Réactiver l'intersection observer après le smooth scroll
setTimeout(() => {
setChapterObserverPaused(false);
}, 1000);
});
}
}