···
+
* Sidenotes Web Components
+
* <p>Text with reference<sidenote-ref for="note1"></sidenote-ref></p>
+
* <side-note id="note1">Note content</side-note>
+
// ============================================
+
// ARTICLE-SIDENOTES - Main Container Component
+
// ============================================
+
class ArticleSidenotes extends HTMLElement {
+
this.attachShadow({ mode: 'open' });
+
this._noteMap = new Map();
+
this._resizeTimeout = null;
+
// Wait for DOM to be ready
+
this.setupPositioning();
+
this.setupHighlighting();
+
disconnectedCallback() {
+
if (this._resizeTimeout) {
+
clearTimeout(this._resizeTimeout);
+
this.shadowRoot.innerHTML = `
+
--content-width: 38rem;
+
--sidenote-width: 18rem;
+
--sidenote-bg: #fafafa;
+
max-width: calc(var(--content-width) + var(--gap) + var(--sidenote-width));
+
@media (prefers-color-scheme: dark) {
+
--sidenote-bg: #252525;
+
max-width: var(--content-width);
+
/* Only show sidenotes in margin when there's enough space */
+
/* 38rem + 2.5rem + 18rem + 2rem padding = 60.5rem ≈ 968px */
+
@media (min-width: 980px) {
+
padding: 0 1rem 0 0.5rem;
+
left: calc(var(--content-width) + var(--gap));
+
width: var(--sidenote-width);
+
color: var(--text-muted);
+
border-left: 2px solid var(--border);
+
transition: all 0.2s ease;
+
.sidenote-wrapper:hover,
+
.sidenote-wrapper.highlighted {
+
border-left-color: var(--accent);
+
.sidenote-wrapper.highlighted {
+
background: var(--accent);
+
padding: 0.5rem 0.5rem 0.5rem 1rem;
+
border-radius: 0 4px 4px 0;
+
.sidenote-wrapper.highlighted .sidenote-number {
+
.sidenote-content img {
+
.sidenote-content pre {
+
background: var(--sidenote-bg);
+
.sidenote-wrapper.highlighted .sidenote-content pre {
+
background: rgba(255, 255, 255, 0.2);
+
/* Ensure slotted content inherits styles */
+
<div class="sidenotes-container" id="sidenotes-container"></div>
+
// Auto-number sidenotes and refs
+
// 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');
+
this._noteMap.set(id, counter);
+
note.setAttribute('data-number', counter);
+
// Apply numbers to refs
+
const forId = ref.getAttribute('for');
+
if (forId && this._noteMap.has(forId)) {
+
ref.setAttribute('data-number', this._noteMap.get(forId));
+
const container = this.shadowRoot.getElementById('sidenotes-container');
+
const sidenotes = this.querySelectorAll('side-note');
+
// Calculate minimum width needed for sidenotes in margin
+
// 38rem + 2.5rem + 18rem + 1.5rem padding = 60rem ≈ 980px
+
const minWidthForSidenotes = 980;
+
// 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 = `<span class="sidenote-number">${note.getAttribute('data-number')}.</span><span class="sidenote-content">${note.innerHTML}</span>`;
+
container.appendChild(wrapper);
+
this.positionSidenotes();
+
// Reposition on resize and scroll
+
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();
+
this.positionSidenotes();
+
// Clear sidenotes container when in mobile view
+
container.innerHTML = '';
+
window.addEventListener('resize', repositionHandler);
+
window.addEventListener('scroll', repositionHandler, { passive: true });
+
// Also reposition after all resources load
+
window.addEventListener('load', () => this.positionSidenotes());
+
const minWidthForSidenotes = 980;
+
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
+
this.querySelectorAll('sidenote-ref').forEach(ref => {
+
const forId = ref.getAttribute('for');
+
const sidenote = container.querySelector(`.sidenote-wrapper[data-id="${forId}"]`);
+
const refRect = ref.getBoundingClientRect();
+
const refTop = refRect.top + window.scrollY;
+
targetTop: refTop - containerTop
+
// Sort by position and place with overlap prevention
+
pairs.sort((a, b) => a.targetTop - b.targetTop);
+
pairs.forEach(({ sidenote, targetTop }) => {
+
const adjustedTop = Math.max(targetTop, lastBottom + 20);
+
sidenote.style.top = adjustedTop + 'px';
+
const height = sidenote.offsetHeight || 100;
+
lastBottom = adjustedTop + height;
+
// 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');
+
const number = wrapper.getAttribute('data-number');
+
wrapper.classList.add('highlighted');
+
// Highlight corresponding reference
+
const ref = this.querySelector(`sidenote-ref[data-number="${number}"]`);
+
if (ref) ref.highlighted = true;
+
container.addEventListener('mouseleave', (e) => {
+
const wrapper = e.target.closest('.sidenote-wrapper');
+
const number = wrapper.getAttribute('data-number');
+
wrapper.classList.remove('highlighted');
+
// Unhighlight corresponding reference
+
const ref = this.querySelector(`sidenote-ref[data-number="${number}"]`);
+
if (ref) ref.highlighted = false;
+
// Listen for ref hover events
+
this.addEventListener('ref-hover', (e) => {
+
const { number, active } = e.detail;
+
const wrapper = container.querySelector(`.sidenote-wrapper[data-number="${number}"]`);
+
wrapper.classList.add('highlighted');
+
wrapper.classList.remove('highlighted');
+
// ============================================
+
// SIDENOTE-REF - Reference Component
+
// ============================================
+
class SidenoteRef extends HTMLElement {
+
static get observedAttributes() {
+
return ['data-number'];
+
this.attachShadow({ mode: 'open' });
+
attributeChangedCallback(name, oldValue, newValue) {
+
if (name === 'data-number' && oldValue !== newValue) {
+
return this.hasAttribute('highlighted');
+
set highlighted(value) {
+
this.setAttribute('highlighted', '');
+
this.removeAttribute('highlighted');
+
this.updateHighlight();
+
const number = this.getAttribute('data-number') || '';
+
this.shadowRoot.innerHTML = `
+
vertical-align: baseline;
+
justify-content: center;
+
color: var(--accent, #0066cc);
+
background: rgba(0, 102, 204, 0.1);
+
border: 1px solid rgba(0, 102, 204, 0.3);
+
transition: all 0.2s ease;
+
font-variant-numeric: tabular-nums;
+
:host([highlighted]) .ref {
+
background: var(--accent, #0066cc);
+
border-color: var(--accent, #0066cc);
+
transform: scale(1.05);
+
<span class="ref">${number}</span>
+
this.addEventListener('mouseenter', () => {
+
const number = this.getAttribute('data-number');
+
this.dispatchEvent(new CustomEvent('ref-hover', {
+
detail: { number, active: true },
+
this.addEventListener('mouseleave', () => {
+
const number = this.getAttribute('data-number');
+
this.dispatchEvent(new CustomEvent('ref-hover', {
+
detail: { number, active: false },
+
// Force re-render to show highlight state
+
const ref = this.shadowRoot.querySelector('.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'];
+
this.attachShadow({ mode: 'open' });
+
attributeChangedCallback(name, oldValue, newValue) {
+
if (name === 'data-number' && oldValue !== newValue) {
+
const number = this.getAttribute('data-number') || '';
+
this.shadowRoot.innerHTML = `
+
color: var(--text-muted, #666);
+
background: var(--sidenote-bg, #fafafa);
+
border-left: 3px solid var(--accent, #0066cc);
+
/* Hide on desktop - they're shown in the sidebar */
+
@media (min-width: 980px) {
+
.sidenote-mobile-number {
+
color: var(--accent, #0066cc);
+
/* Mobile/tablet styles */
+
@media (max-width: 979px) {
+
.sidenote-mobile-number {
+
@media (min-width: 980px) {
+
.sidenote-mobile-number {
+
background: rgba(0, 0, 0, 0.05);
+
${number ? `<span class="sidenote-mobile-number">${number}.</span>` : ''}
+
// Register components with proper hyphenated names
+
customElements.define('article-sidenotes', ArticleSidenotes);
+
customElements.define('sidenote-ref', SidenoteRef);
+
customElements.define('side-note', SideNote);