/** * Sidenotes Web Components * * Usage: * *

Text with reference

* Note content *
*/ // ============================================ // ARTICLE-SIDENOTES - Main Container Component // ============================================ class ArticleSidenotes extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this._noteCounter = 0; this._noteMap = new Map(); this._resizeTimeout = null; } connectedCallback() { this.render(); // Wait for DOM to be ready setTimeout(() => { this.processNotes(); this.setupPositioning(); this.setupHighlighting(); }, 0); } disconnectedCallback() { if (this._resizeTimeout) { clearTimeout(this._resizeTimeout); } } render() { this.shadowRoot.innerHTML = `
`; } processNotes() { // Auto-number sidenotes and refs let counter = 1; // Find all sidenote elements const sidenotes = this.querySelectorAll('side-note'); const refs = this.querySelectorAll('sidenote-ref'); // Create a map of note IDs to numbers sidenotes.forEach(note => { const id = note.getAttribute('id'); if (id) { this._noteMap.set(id, counter); note.setAttribute('data-number', counter); counter++; } }); // Apply numbers to refs refs.forEach(ref => { const forId = ref.getAttribute('for'); if (forId && this._noteMap.has(forId)) { ref.setAttribute('data-number', this._noteMap.get(forId)); } }); } setupPositioning() { const container = this.shadowRoot.getElementById('sidenotes-container'); const sidenotes = this.querySelectorAll('side-note'); // Calculate minimum width needed for sidenotes in margin // 40rem + 2.5rem + 18rem + 1.5rem padding = 62rem ≈ 1000px const minWidthForSidenotes = 1000; // Clear existing clones container.innerHTML = ''; // Only clone if we have enough space if (window.innerWidth >= minWidthForSidenotes) { // Clone sidenotes to the container for desktop display sidenotes.forEach(note => { // Create a wrapper div to hold the sidenote const wrapper = document.createElement('div'); wrapper.className = 'sidenote-wrapper'; wrapper.setAttribute('data-number', note.getAttribute('data-number')); wrapper.setAttribute('data-id', note.getAttribute('id')); // Copy the inner HTML directly to preserve content wrapper.innerHTML = `${note.getAttribute('data-number')}.${note.innerHTML}`; container.appendChild(wrapper); }); // Position sidenotes this.positionSidenotes(); } // Reposition on resize and scroll let resizeTimeout; const repositionHandler = () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { const currentWidth = window.innerWidth; if (currentWidth >= minWidthForSidenotes) { // Re-setup if transitioning from mobile to desktop if (container.children.length === 0) { this.setupPositioning(); } else { this.positionSidenotes(); } } else { // Clear sidenotes container when in mobile view container.innerHTML = ''; } }, 150); }; window.addEventListener('resize', repositionHandler); window.addEventListener('scroll', repositionHandler, { passive: true }); // Also reposition after all resources load window.addEventListener('load', () => this.positionSidenotes()); } positionSidenotes() { const minWidthForSidenotes = 1000; if (window.innerWidth < minWidthForSidenotes) return; const container = this.shadowRoot.getElementById('sidenotes-container'); const containerRect = container.getBoundingClientRect(); const containerTop = containerRect.top + window.scrollY; // Collect reference-sidenote pairs const pairs = []; this.querySelectorAll('sidenote-ref').forEach(ref => { const forId = ref.getAttribute('for'); const sidenote = container.querySelector(`.sidenote-wrapper[data-id="${forId}"]`); if (sidenote) { const refRect = ref.getBoundingClientRect(); const refTop = refRect.top + window.scrollY; pairs.push({ sidenote, targetTop: refTop - containerTop }); } }); // Sort by position and place with overlap prevention pairs.sort((a, b) => a.targetTop - b.targetTop); let lastBottom = 0; pairs.forEach(({ sidenote, targetTop }) => { const adjustedTop = Math.max(targetTop, lastBottom + 20); sidenote.style.top = adjustedTop + 'px'; const height = sidenote.offsetHeight || 100; lastBottom = adjustedTop + height; }); } setupHighlighting() { // Setup hover events on wrapper elements in shadow DOM const container = this.shadowRoot.getElementById('sidenotes-container'); container.addEventListener('mouseenter', (e) => { const wrapper = e.target.closest('.sidenote-wrapper'); if (wrapper) { const number = wrapper.getAttribute('data-number'); // Highlight corresponding reference const ref = this.querySelector(`sidenote-ref[data-number="${number}"]`); if (ref) ref.highlighted = true; } }, true); container.addEventListener('mouseleave', (e) => { const wrapper = e.target.closest('.sidenote-wrapper'); if (wrapper) { const number = wrapper.getAttribute('data-number'); // Unhighlight corresponding reference const ref = this.querySelector(`sidenote-ref[data-number="${number}"]`); if (ref) ref.highlighted = false; } }, true); // Listen for ref hover events - currently not needed but kept for future use } } // ============================================ // SIDENOTE-REF - Reference Component // ============================================ class SidenoteRef extends HTMLElement { static get observedAttributes() { return ['data-number']; } constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { this.render(); this.setupEvents(); } attributeChangedCallback(name, oldValue, newValue) { if (name === 'data-number' && oldValue !== newValue) { this.render(); } } get highlighted() { return this.hasAttribute('highlighted'); } set highlighted(value) { if (value) { this.setAttribute('highlighted', ''); } else { this.removeAttribute('highlighted'); } this.updateHighlight(); } render() { const number = this.getAttribute('data-number') || ''; this.shadowRoot.innerHTML = ` ${number} `; } setupEvents() { this.addEventListener('mouseenter', () => { const number = this.getAttribute('data-number'); this.dispatchEvent(new CustomEvent('ref-hover', { detail: { number, active: true }, bubbles: true })); }); this.addEventListener('mouseleave', () => { const number = this.getAttribute('data-number'); this.dispatchEvent(new CustomEvent('ref-hover', { detail: { number, active: false }, bubbles: true })); }); } updateHighlight() { // Force re-render to show highlight state const ref = this.shadowRoot.querySelector('.ref'); if (ref) { ref.style.background = this.highlighted ? 'var(--accent, #0066cc)' : ''; ref.style.color = this.highlighted ? 'white' : ''; } } } // ============================================ // SIDE-NOTE - Note Content Component // ============================================ class SideNote extends HTMLElement { static get observedAttributes() { return ['data-number']; } constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { this.render(); } attributeChangedCallback(name, oldValue, newValue) { if (name === 'data-number' && oldValue !== newValue) { this.render(); } } render() { const number = this.getAttribute('data-number') || ''; this.shadowRoot.innerHTML = `
${number ? `${number}.` : ''}
`; } } // Register components with proper hyphenated names customElements.define('article-sidenotes', ArticleSidenotes); customElements.define('sidenote-ref', SidenoteRef); customElements.define('side-note', SideNote);