···
2
+
* Sidenotes Web Components
5
+
* <article-sidenotes>
6
+
* <p>Text with reference<sidenote-ref for="note1"></sidenote-ref></p>
7
+
* <side-note id="note1">Note content</side-note>
8
+
* </article-sidenotes>
11
+
// ============================================
12
+
// ARTICLE-SIDENOTES - Main Container Component
13
+
// ============================================
14
+
class ArticleSidenotes extends HTMLElement {
17
+
this.attachShadow({ mode: 'open' });
18
+
this._noteCounter = 0;
19
+
this._noteMap = new Map();
20
+
this._resizeTimeout = null;
23
+
connectedCallback() {
25
+
// Wait for DOM to be ready
27
+
this.processNotes();
28
+
this.setupPositioning();
29
+
this.setupHighlighting();
33
+
disconnectedCallback() {
34
+
if (this._resizeTimeout) {
35
+
clearTimeout(this._resizeTimeout);
40
+
this.shadowRoot.innerHTML = `
43
+
--content-width: 38rem;
44
+
--sidenote-width: 18rem;
50
+
--sidenote-bg: #fafafa;
55
+
max-width: calc(var(--content-width) + var(--gap) + var(--sidenote-width));
60
+
@media (prefers-color-scheme: dark) {
66
+
--sidenote-bg: #252525;
72
+
max-width: var(--content-width);
75
+
.sidenotes-container {
79
+
/* Only show sidenotes in margin when there's enough space */
80
+
/* 38rem + 2.5rem + 18rem + 2rem padding = 60.5rem โ 968px */
81
+
@media (min-width: 980px) {
83
+
padding: 0 1rem 0 0.5rem;
86
+
.sidenotes-container {
89
+
left: calc(var(--content-width) + var(--gap));
91
+
width: var(--sidenote-width);
100
+
color: var(--text-muted);
101
+
padding-left: 1rem;
102
+
border-left: 2px solid var(--border);
103
+
transition: all 0.2s ease;
106
+
.sidenote-wrapper:hover,
107
+
.sidenote-wrapper.highlighted {
108
+
border-left-color: var(--accent);
109
+
color: var(--text);
112
+
.sidenote-wrapper.highlighted {
113
+
background: var(--accent);
115
+
padding: 0.5rem 0.5rem 0.5rem 1rem;
116
+
border-radius: 0 4px 4px 0;
123
+
color: var(--accent);
124
+
margin-right: 0.3em;
127
+
.sidenote-wrapper.highlighted .sidenote-number {
131
+
.sidenote-content {
135
+
.sidenote-content img {
140
+
border-radius: 4px;
143
+
.sidenote-content pre {
144
+
background: var(--sidenote-bg);
146
+
border-radius: 4px;
150
+
.sidenote-wrapper.highlighted .sidenote-content pre {
151
+
background: rgba(255, 255, 255, 0.2);
156
+
/* Ensure slotted content inherits styles */
160
+
<div class="content">
163
+
<div class="sidenotes-container" id="sidenotes-container"></div>
168
+
// Auto-number sidenotes and refs
171
+
// Find all sidenote elements
172
+
const sidenotes = this.querySelectorAll('side-note');
173
+
const refs = this.querySelectorAll('sidenote-ref');
175
+
// Create a map of note IDs to numbers
176
+
sidenotes.forEach(note => {
177
+
const id = note.getAttribute('id');
179
+
this._noteMap.set(id, counter);
180
+
note.setAttribute('data-number', counter);
185
+
// Apply numbers to refs
186
+
refs.forEach(ref => {
187
+
const forId = ref.getAttribute('for');
188
+
if (forId && this._noteMap.has(forId)) {
189
+
ref.setAttribute('data-number', this._noteMap.get(forId));
194
+
setupPositioning() {
195
+
const container = this.shadowRoot.getElementById('sidenotes-container');
196
+
const sidenotes = this.querySelectorAll('side-note');
198
+
// Calculate minimum width needed for sidenotes in margin
199
+
// 38rem + 2.5rem + 18rem + 1.5rem padding = 60rem โ 980px
200
+
const minWidthForSidenotes = 980;
202
+
// Clear existing clones
203
+
container.innerHTML = '';
205
+
// Only clone if we have enough space
206
+
if (window.innerWidth >= minWidthForSidenotes) {
207
+
// Clone sidenotes to the container for desktop display
208
+
sidenotes.forEach(note => {
209
+
// Create a wrapper div to hold the sidenote
210
+
const wrapper = document.createElement('div');
211
+
wrapper.className = 'sidenote-wrapper';
212
+
wrapper.setAttribute('data-number', note.getAttribute('data-number'));
213
+
wrapper.setAttribute('data-id', note.getAttribute('id'));
215
+
// Copy the inner HTML directly to preserve content
216
+
wrapper.innerHTML = `<span class="sidenote-number">${note.getAttribute('data-number')}.</span><span class="sidenote-content">${note.innerHTML}</span>`;
218
+
container.appendChild(wrapper);
221
+
// Position sidenotes
222
+
this.positionSidenotes();
225
+
// Reposition on resize and scroll
227
+
const repositionHandler = () => {
228
+
clearTimeout(resizeTimeout);
229
+
resizeTimeout = setTimeout(() => {
230
+
const currentWidth = window.innerWidth;
232
+
if (currentWidth >= minWidthForSidenotes) {
233
+
// Re-setup if transitioning from mobile to desktop
234
+
if (container.children.length === 0) {
235
+
this.setupPositioning();
237
+
this.positionSidenotes();
240
+
// Clear sidenotes container when in mobile view
241
+
container.innerHTML = '';
246
+
window.addEventListener('resize', repositionHandler);
247
+
window.addEventListener('scroll', repositionHandler, { passive: true });
249
+
// Also reposition after all resources load
250
+
window.addEventListener('load', () => this.positionSidenotes());
253
+
positionSidenotes() {
254
+
const minWidthForSidenotes = 980;
255
+
if (window.innerWidth < minWidthForSidenotes) return;
257
+
const container = this.shadowRoot.getElementById('sidenotes-container');
258
+
const containerRect = container.getBoundingClientRect();
259
+
const containerTop = containerRect.top + window.scrollY;
261
+
// Collect reference-sidenote pairs
263
+
this.querySelectorAll('sidenote-ref').forEach(ref => {
264
+
const forId = ref.getAttribute('for');
265
+
const sidenote = container.querySelector(`.sidenote-wrapper[data-id="${forId}"]`);
268
+
const refRect = ref.getBoundingClientRect();
269
+
const refTop = refRect.top + window.scrollY;
273
+
targetTop: refTop - containerTop
278
+
// Sort by position and place with overlap prevention
279
+
pairs.sort((a, b) => a.targetTop - b.targetTop);
281
+
let lastBottom = 0;
282
+
pairs.forEach(({ sidenote, targetTop }) => {
283
+
const adjustedTop = Math.max(targetTop, lastBottom + 20);
284
+
sidenote.style.top = adjustedTop + 'px';
286
+
const height = sidenote.offsetHeight || 100;
287
+
lastBottom = adjustedTop + height;
291
+
setupHighlighting() {
292
+
// Setup hover events on wrapper elements in shadow DOM
293
+
const container = this.shadowRoot.getElementById('sidenotes-container');
295
+
container.addEventListener('mouseenter', (e) => {
296
+
const wrapper = e.target.closest('.sidenote-wrapper');
298
+
const number = wrapper.getAttribute('data-number');
299
+
wrapper.classList.add('highlighted');
300
+
// Highlight corresponding reference
301
+
const ref = this.querySelector(`sidenote-ref[data-number="${number}"]`);
302
+
if (ref) ref.highlighted = true;
306
+
container.addEventListener('mouseleave', (e) => {
307
+
const wrapper = e.target.closest('.sidenote-wrapper');
309
+
const number = wrapper.getAttribute('data-number');
310
+
wrapper.classList.remove('highlighted');
311
+
// Unhighlight corresponding reference
312
+
const ref = this.querySelector(`sidenote-ref[data-number="${number}"]`);
313
+
if (ref) ref.highlighted = false;
317
+
// Listen for ref hover events
318
+
this.addEventListener('ref-hover', (e) => {
319
+
const { number, active } = e.detail;
320
+
const wrapper = container.querySelector(`.sidenote-wrapper[data-number="${number}"]`);
323
+
wrapper.classList.add('highlighted');
325
+
wrapper.classList.remove('highlighted');
332
+
// ============================================
333
+
// SIDENOTE-REF - Reference Component
334
+
// ============================================
335
+
class SidenoteRef extends HTMLElement {
336
+
static get observedAttributes() {
337
+
return ['data-number'];
342
+
this.attachShadow({ mode: 'open' });
345
+
connectedCallback() {
347
+
this.setupEvents();
350
+
attributeChangedCallback(name, oldValue, newValue) {
351
+
if (name === 'data-number' && oldValue !== newValue) {
356
+
get highlighted() {
357
+
return this.hasAttribute('highlighted');
360
+
set highlighted(value) {
362
+
this.setAttribute('highlighted', '');
364
+
this.removeAttribute('highlighted');
366
+
this.updateHighlight();
370
+
const number = this.getAttribute('data-number') || '';
372
+
this.shadowRoot.innerHTML = `
375
+
display: inline-block;
376
+
vertical-align: baseline;
383
+
display: inline-flex;
384
+
align-items: center;
385
+
justify-content: center;
389
+
color: var(--accent, #0066cc);
390
+
background: rgba(0, 102, 204, 0.1);
391
+
border: 1px solid rgba(0, 102, 204, 0.3);
392
+
border-radius: 3px;
394
+
text-decoration: none;
395
+
transition: all 0.2s ease;
396
+
font-variant-numeric: tabular-nums;
400
+
:host([highlighted]) .ref {
401
+
background: var(--accent, #0066cc);
402
+
border-color: var(--accent, #0066cc);
404
+
transform: scale(1.05);
407
+
<span class="ref">${number}</span>
412
+
this.addEventListener('mouseenter', () => {
413
+
const number = this.getAttribute('data-number');
414
+
this.dispatchEvent(new CustomEvent('ref-hover', {
415
+
detail: { number, active: true },
420
+
this.addEventListener('mouseleave', () => {
421
+
const number = this.getAttribute('data-number');
422
+
this.dispatchEvent(new CustomEvent('ref-hover', {
423
+
detail: { number, active: false },
429
+
updateHighlight() {
430
+
// Force re-render to show highlight state
431
+
const ref = this.shadowRoot.querySelector('.ref');
433
+
ref.style.background = this.highlighted ? 'var(--accent, #0066cc)' : '';
434
+
ref.style.color = this.highlighted ? 'white' : '';
439
+
// ============================================
440
+
// SIDE-NOTE - Note Content Component
441
+
// ============================================
442
+
class SideNote extends HTMLElement {
443
+
static get observedAttributes() {
444
+
return ['data-number'];
449
+
this.attachShadow({ mode: 'open' });
452
+
connectedCallback() {
456
+
attributeChangedCallback(name, oldValue, newValue) {
457
+
if (name === 'data-number' && oldValue !== newValue) {
463
+
const number = this.getAttribute('data-number') || '';
465
+
this.shadowRoot.innerHTML = `
469
+
font-size: 0.85rem;
471
+
color: var(--text-muted, #666);
474
+
background: var(--sidenote-bg, #fafafa);
475
+
border-left: 3px solid var(--accent, #0066cc);
476
+
border-radius: 4px;
479
+
/* Hide on desktop - they're shown in the sidebar */
480
+
@media (min-width: 980px) {
486
+
.sidenote-mobile-number {
488
+
color: var(--accent, #0066cc);
489
+
margin-right: 0.5em;
492
+
/* Mobile/tablet styles */
493
+
@media (max-width: 979px) {
494
+
.sidenote-mobile-number {
499
+
@media (min-width: 980px) {
500
+
.sidenote-mobile-number {
512
+
border-radius: 4px;
516
+
background: rgba(0, 0, 0, 0.05);
518
+
border-radius: 4px;
522
+
${number ? `<span class="sidenote-mobile-number">${number}.</span>` : ''}
528
+
// Register components with proper hyphenated names
529
+
customElements.define('article-sidenotes', ArticleSidenotes);
530
+
customElements.define('sidenote-ref', SidenoteRef);
531
+
customElements.define('side-note', SideNote);