/**
* 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);