bloggy 0.1.40__py3-none-any.whl → 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bloggy/static/scripts.js CHANGED
@@ -1,12 +1,146 @@
1
1
  import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
2
2
 
3
3
  const mermaidStates = {};
4
+ const mermaidDebugEnabled = () => (
5
+ window.BLOGGY_DEBUG_MERMAID === true ||
6
+ localStorage.getItem('bloggyDebugMermaid') === '1'
7
+ );
8
+ const mermaidDebugLog = (...args) => {
9
+ if (mermaidDebugEnabled()) {
10
+ console.log('[bloggy][mermaid]', ...args);
11
+ }
12
+ };
13
+ const mermaidDebugSnapshot = (label) => {
14
+ if (!mermaidDebugEnabled()) {
15
+ return;
16
+ }
17
+ const wrappers = Array.from(document.querySelectorAll('.mermaid-wrapper'));
18
+ const withSvg = wrappers.filter(w => w.querySelector('svg'));
19
+ const interactive = wrappers.filter(w => w.dataset.mermaidInteractive === 'true');
20
+ const last = wrappers[wrappers.length - 1];
21
+ let lastRect = null;
22
+ if (last) {
23
+ const rect = last.getBoundingClientRect();
24
+ lastRect = {
25
+ id: last.id,
26
+ width: Math.round(rect.width),
27
+ height: Math.round(rect.height),
28
+ hasSvg: !!last.querySelector('svg'),
29
+ interactive: last.dataset.mermaidInteractive === 'true'
30
+ };
31
+ }
32
+ mermaidDebugLog(label, {
33
+ total: wrappers.length,
34
+ withSvg: withSvg.length,
35
+ interactive: interactive.length,
36
+ last: lastRect
37
+ });
38
+ };
4
39
  const GANTT_WIDTH = 1200;
5
40
 
41
+ function handleCodeCopyClick(event) {
42
+ const button = event.target.closest('.code-copy-button, .hljs-copy-button');
43
+ if (!button) {
44
+ return;
45
+ }
46
+ event.preventDefault();
47
+ event.stopPropagation();
48
+ const container = button.closest('.code-block') || button.closest('pre') || button.parentElement;
49
+ const textarea = container ? container.querySelector('textarea[id$="-clipboard"]') : null;
50
+ let text = '';
51
+ if (textarea && textarea.value) {
52
+ text = textarea.value;
53
+ } else {
54
+ const codeEl = (container && container.querySelector('pre > code')) ||
55
+ (container && container.querySelector('code')) ||
56
+ button.closest('pre');
57
+ if (!codeEl) {
58
+ return;
59
+ }
60
+ text = codeEl.innerText || codeEl.textContent || '';
61
+ }
62
+ const showToast = () => {
63
+ let toast = document.getElementById('code-copy-toast');
64
+ if (!toast) {
65
+ toast = document.createElement('div');
66
+ toast.id = 'code-copy-toast';
67
+ toast.className = 'fixed top-6 right-6 z-[10000] text-xs bg-slate-900 text-white px-3 py-2 rounded shadow-lg opacity-0 transition-opacity duration-300';
68
+ toast.textContent = 'Copied';
69
+ document.body.appendChild(toast);
70
+ }
71
+ toast.classList.remove('opacity-0');
72
+ toast.classList.add('opacity-100');
73
+ setTimeout(() => {
74
+ toast.classList.remove('opacity-100');
75
+ toast.classList.add('opacity-0');
76
+ }, 1400);
77
+ };
78
+ if (navigator.clipboard && window.isSecureContext) {
79
+ navigator.clipboard.writeText(text).then(showToast).catch(() => {
80
+ const textarea = document.createElement('textarea');
81
+ textarea.value = text;
82
+ textarea.setAttribute('readonly', '');
83
+ textarea.style.position = 'absolute';
84
+ textarea.style.left = '-9999px';
85
+ document.body.appendChild(textarea);
86
+ textarea.select();
87
+ document.execCommand('copy');
88
+ document.body.removeChild(textarea);
89
+ showToast();
90
+ });
91
+ } else {
92
+ const textarea = document.createElement('textarea');
93
+ textarea.value = text;
94
+ textarea.setAttribute('readonly', '');
95
+ textarea.style.position = 'absolute';
96
+ textarea.style.left = '-9999px';
97
+ document.body.appendChild(textarea);
98
+ textarea.select();
99
+ document.execCommand('copy');
100
+ document.body.removeChild(textarea);
101
+ showToast();
102
+ }
103
+ }
104
+
105
+ document.addEventListener('click', handleCodeCopyClick, true);
106
+
6
107
  function initMermaidInteraction() {
7
- document.querySelectorAll('.mermaid-wrapper').forEach(wrapper => {
108
+ const wrappers = Array.from(document.querySelectorAll('.mermaid-wrapper'));
109
+ if (mermaidDebugEnabled()) {
110
+ const pending = wrappers.filter(w => !w.querySelector('svg'));
111
+ const last = wrappers[wrappers.length - 1];
112
+ mermaidDebugLog('initMermaidInteraction: total', wrappers.length, 'pending', pending.length);
113
+ if (last) {
114
+ mermaidDebugLog('initMermaidInteraction: last wrapper', last.id, 'hasSvg', !!last.querySelector('svg'));
115
+ }
116
+ }
117
+ wrappers.forEach((wrapper, idx) => {
8
118
  const svg = wrapper.querySelector('svg');
9
- if (!svg || mermaidStates[wrapper.id]) return;
119
+ const alreadyInteractive = wrapper.dataset.mermaidInteractive === 'true';
120
+ if (mermaidDebugEnabled()) {
121
+ mermaidDebugLog(
122
+ 'initMermaidInteraction: wrapper',
123
+ idx,
124
+ wrapper.id,
125
+ 'hasSvg',
126
+ !!svg,
127
+ 'interactive',
128
+ alreadyInteractive
129
+ );
130
+ }
131
+ const getSvg = () => wrapper.querySelector('svg');
132
+ const applySvgState = (currentSvg) => {
133
+ if (!currentSvg) {
134
+ return;
135
+ }
136
+ currentSvg.style.pointerEvents = 'none';
137
+ currentSvg.style.transform = `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`;
138
+ currentSvg.style.transformOrigin = 'center center';
139
+ };
140
+ if (svg) {
141
+ svg.style.pointerEvents = 'none';
142
+ }
143
+ if (!svg || alreadyInteractive) return;
10
144
 
11
145
  // DEBUG: Log initial state
12
146
  console.group(`🔍 initMermaidInteraction: ${wrapper.id}`);
@@ -26,16 +160,28 @@ function initMermaidInteraction() {
26
160
 
27
161
  // For very wide diagrams (like Gantt charts), prefer width scaling even if it exceeds height
28
162
  const aspectRatio = svgRect.width / svgRect.height;
163
+ const maxUpscale = 1;
29
164
  let initialScale;
30
165
  if (aspectRatio > 3) {
31
- // Wide diagram: scale to fit width, allowing vertical scroll if needed
32
- initialScale = scaleX;
166
+ // Wide diagram: scale to fit width, but do not upscale by default
167
+ initialScale = Math.min(scaleX, maxUpscale);
33
168
  console.log('Wide diagram detected (aspect ratio > 3):', aspectRatio, 'Using scaleX:', initialScale);
34
169
  } else {
35
- // Normal diagram: fit to smaller dimension, but allow upscaling up to 3x
36
- initialScale = Math.min(scaleX, scaleY, 3);
170
+ // Normal diagram: fit to smaller dimension, but do not upscale by default
171
+ initialScale = Math.min(scaleX, scaleY, maxUpscale);
37
172
  console.log('Normal diagram (aspect ratio <=3):', aspectRatio, 'Using min scale:', initialScale);
38
173
  }
174
+
175
+ if (mermaidDebugEnabled()) {
176
+ mermaidDebugLog('initMermaidInteraction: sizing', {
177
+ id: wrapper.id,
178
+ wrapperWidth: wrapperRect.width,
179
+ wrapperHeight: wrapperRect.height,
180
+ svgWidth: svgRect.width,
181
+ svgHeight: svgRect.height,
182
+ initialScale
183
+ });
184
+ }
39
185
 
40
186
  const state = {
41
187
  scale: initialScale,
@@ -46,22 +192,46 @@ function initMermaidInteraction() {
46
192
  startY: 0
47
193
  };
48
194
  mermaidStates[wrapper.id] = state;
195
+ wrapper.dataset.mermaidInteractive = 'true';
49
196
  console.log('Final state:', state);
50
197
  console.groupEnd();
198
+
199
+ if (mermaidDebugEnabled() && !wrapper.dataset.mermaidDebugBound) {
200
+ wrapper.dataset.mermaidDebugBound = 'true';
201
+ const logEvent = (name, event) => {
202
+ const target = event.target && event.target.tagName ? event.target.tagName : 'unknown';
203
+ mermaidDebugLog(`${name} on ${wrapper.id}`, { type: event.type, target });
204
+ };
205
+ wrapper.addEventListener('pointerdown', (e) => logEvent('pointerdown', e));
206
+ wrapper.addEventListener('pointermove', (e) => logEvent('pointermove', e));
207
+ wrapper.addEventListener('pointerup', (e) => logEvent('pointerup', e));
208
+ wrapper.addEventListener('wheel', (e) => logEvent('wheel', e));
209
+ }
51
210
 
52
211
  function updateTransform() {
53
- svg.style.transform = `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`;
54
- svg.style.transformOrigin = 'center center';
212
+ applySvgState(getSvg());
55
213
  }
56
214
 
57
215
  // Apply initial scale
58
216
  updateTransform();
217
+
218
+ if (!wrapper.dataset.mermaidObserver) {
219
+ const observer = new MutationObserver(() => {
220
+ applySvgState(getSvg());
221
+ });
222
+ observer.observe(wrapper, { childList: true, subtree: true });
223
+ wrapper.dataset.mermaidObserver = 'true';
224
+ }
59
225
 
60
226
  // Mouse wheel zoom (zooms towards cursor position)
61
227
  wrapper.addEventListener('wheel', (e) => {
62
228
  e.preventDefault();
63
229
 
64
- const rect = svg.getBoundingClientRect();
230
+ const currentSvg = getSvg();
231
+ if (!currentSvg) {
232
+ return;
233
+ }
234
+ const rect = currentSvg.getBoundingClientRect();
65
235
 
66
236
  // Mouse position relative to SVG's current position
67
237
  const mouseX = e.clientX - rect.left - rect.width / 2;
@@ -81,34 +251,75 @@ function initMermaidInteraction() {
81
251
  updateTransform();
82
252
  }, { passive: false });
83
253
 
84
- // Pan with mouse drag
85
- wrapper.addEventListener('mousedown', (e) => {
86
- if (e.button !== 0) return;
254
+ // Pan with pointer drag (mouse + touch)
255
+ wrapper.style.cursor = 'grab';
256
+ wrapper.style.touchAction = 'none';
257
+ wrapper.addEventListener('pointerdown', (e) => {
258
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
87
259
  state.isPanning = true;
88
260
  state.startX = e.clientX - state.translateX;
89
261
  state.startY = e.clientY - state.translateY;
262
+ wrapper.setPointerCapture(e.pointerId);
90
263
  wrapper.style.cursor = 'grabbing';
91
264
  e.preventDefault();
92
265
  });
93
266
 
94
- document.addEventListener('mousemove', (e) => {
267
+ wrapper.addEventListener('pointermove', (e) => {
95
268
  if (!state.isPanning) return;
96
269
  state.translateX = e.clientX - state.startX;
97
270
  state.translateY = e.clientY - state.startY;
98
271
  updateTransform();
272
+ if (mermaidDebugEnabled()) {
273
+ mermaidDebugLog('pan update', wrapper.id, {
274
+ translateX: state.translateX,
275
+ translateY: state.translateY,
276
+ scale: state.scale,
277
+ svgTransform: (getSvg() && getSvg().style.transform) || ''
278
+ });
279
+ }
99
280
  });
100
281
 
101
- document.addEventListener('mouseup', () => {
102
- if (state.isPanning) {
103
- state.isPanning = false;
104
- wrapper.style.cursor = 'grab';
282
+ const stopPanning = (e) => {
283
+ if (!state.isPanning) return;
284
+ state.isPanning = false;
285
+ try {
286
+ wrapper.releasePointerCapture(e.pointerId);
287
+ } catch {
288
+ // Ignore if pointer capture is not active
105
289
  }
106
- });
290
+ wrapper.style.cursor = 'grab';
291
+ };
107
292
 
108
- wrapper.style.cursor = 'grab';
293
+ wrapper.addEventListener('pointerup', stopPanning);
294
+ wrapper.addEventListener('pointercancel', stopPanning);
109
295
  });
110
296
  }
111
297
 
298
+ function scheduleMermaidInteraction({ maxAttempts = 12, delayMs = 80, onReady } = {}) {
299
+ let attempt = 0;
300
+ const check = () => {
301
+ const wrappers = Array.from(document.querySelectorAll('.mermaid-wrapper'));
302
+ const pending = wrappers.filter(wrapper => !wrapper.querySelector('svg'));
303
+ if (mermaidDebugEnabled()) {
304
+ const last = wrappers[wrappers.length - 1];
305
+ mermaidDebugLog('scheduleMermaidInteraction attempt', attempt, 'pending', pending.length);
306
+ if (last) {
307
+ mermaidDebugLog('scheduleMermaidInteraction last wrapper', last.id, 'hasSvg', !!last.querySelector('svg'));
308
+ }
309
+ }
310
+ if (pending.length === 0 || attempt >= maxAttempts) {
311
+ initMermaidInteraction();
312
+ if (typeof onReady === 'function') {
313
+ onReady();
314
+ }
315
+ return;
316
+ }
317
+ attempt += 1;
318
+ setTimeout(check, delayMs);
319
+ };
320
+ check();
321
+ }
322
+
112
323
  window.resetMermaidZoom = function(id) {
113
324
  const state = mermaidStates[id];
114
325
  if (state) {
@@ -263,6 +474,11 @@ function reinitializeMermaid() {
263
474
  });
264
475
 
265
476
  // Find all mermaid wrappers and re-render them
477
+ const shouldLockHeight = (wrapper) => {
478
+ const height = (wrapper.style.height || '').trim();
479
+ return height && height !== 'auto' && height !== 'initial' && height !== 'unset';
480
+ };
481
+
266
482
  document.querySelectorAll('.mermaid-wrapper').forEach(wrapper => {
267
483
  const originalCode = wrapper.getAttribute('data-mermaid-code');
268
484
  if (originalCode) {
@@ -271,12 +487,15 @@ function reinitializeMermaid() {
271
487
  console.log('BEFORE clear - wrapper rect:', wrapper.getBoundingClientRect());
272
488
 
273
489
  // Preserve the current computed height before clearing (height should already be set explicitly)
274
- const currentHeight = wrapper.getBoundingClientRect().height;
275
- console.log('Preserving height:', currentHeight);
276
- wrapper.style.height = currentHeight + 'px';
490
+ if (shouldLockHeight(wrapper)) {
491
+ const currentHeight = wrapper.getBoundingClientRect().height;
492
+ console.log('Preserving height:', currentHeight);
493
+ wrapper.style.height = currentHeight + 'px';
494
+ }
277
495
 
278
496
  // Delete the old state so it can be recreated
279
497
  delete mermaidStates[wrapper.id];
498
+ delete wrapper.dataset.mermaidInteractive;
280
499
 
281
500
  // Decode HTML entities
282
501
  const textarea = document.createElement('textarea');
@@ -298,11 +517,12 @@ function reinitializeMermaid() {
298
517
 
299
518
  // Re-run mermaid
300
519
  mermaid.run().then(() => {
301
- console.log('Mermaid re-render complete, calling initMermaidInteraction in 100ms');
302
- setTimeout(() => {
303
- initMermaidInteraction();
304
- console.groupEnd();
305
- }, 100);
520
+ console.log('Mermaid re-render complete, scheduling initMermaidInteraction');
521
+ scheduleMermaidInteraction({
522
+ onReady: () => {
523
+ console.groupEnd();
524
+ }
525
+ });
306
526
  });
307
527
  }
308
528
 
@@ -312,7 +532,7 @@ const initialGanttWidth = getDynamicGanttWidth();
312
532
  console.log('Using initial Gantt width:', initialGanttWidth);
313
533
 
314
534
  mermaid.initialize({
315
- startOnLoad: true,
535
+ startOnLoad: false,
316
536
  theme: getCurrentTheme(),
317
537
  gantt: {
318
538
  useWidth: initialGanttWidth,
@@ -324,20 +544,32 @@ mermaid.initialize({
324
544
  let isInitialLoad = true;
325
545
 
326
546
  // Initialize interaction after mermaid renders
327
- mermaid.run().then(() => {
328
- console.log('Initial mermaid render complete');
329
- setTimeout(() => {
330
- console.log('Calling initial initMermaidInteraction');
331
- initMermaidInteraction();
332
-
333
- // After initial render, set explicit heights on all wrappers so theme switching works
334
- document.querySelectorAll('.mermaid-wrapper').forEach(wrapper => {
335
- const currentHeight = wrapper.getBoundingClientRect().height;
336
- console.log(`Setting initial height for ${wrapper.id}:`, currentHeight);
337
- wrapper.style.height = currentHeight + 'px';
547
+ document.addEventListener('DOMContentLoaded', () => {
548
+ mermaidDebugSnapshot('before mermaid.run (DOMContentLoaded)');
549
+ mermaid.run().then(() => {
550
+ mermaidDebugSnapshot('after mermaid.run (DOMContentLoaded)');
551
+ console.log('Initial mermaid render complete');
552
+ scheduleMermaidInteraction({
553
+ onReady: () => {
554
+ console.log('Calling initial initMermaidInteraction');
555
+
556
+ // After initial render, set explicit heights on all wrappers so theme switching works
557
+ const shouldLockHeight = (wrapper) => {
558
+ const height = (wrapper.style.height || '').trim();
559
+ return height && height !== 'auto' && height !== 'initial' && height !== 'unset';
560
+ };
561
+ document.querySelectorAll('.mermaid-wrapper').forEach(wrapper => {
562
+ if (!shouldLockHeight(wrapper)) {
563
+ return;
564
+ }
565
+ const currentHeight = wrapper.getBoundingClientRect().height;
566
+ console.log(`Setting initial height for ${wrapper.id}:`, currentHeight);
567
+ wrapper.style.height = currentHeight + 'px';
568
+ });
569
+ isInitialLoad = false;
570
+ }
338
571
  });
339
- isInitialLoad = false;
340
- }, 100);
572
+ });
341
573
  });
342
574
 
343
575
  // Reveal current file in sidebar
@@ -346,7 +578,8 @@ function revealInSidebar(rootElement = document) {
346
578
  return;
347
579
  }
348
580
 
349
- const currentPath = window.location.pathname.replace(/^\/posts\//, '');
581
+ // Decode the URL path to handle special characters and spaces
582
+ const currentPath = decodeURIComponent(window.location.pathname.replace(/^\/posts\//, ''));
350
583
  const activeLink = rootElement.querySelector(`.post-link[data-path="${currentPath}"]`);
351
584
 
352
585
  if (activeLink) {
@@ -375,20 +608,33 @@ function revealInSidebar(rootElement = document) {
375
608
  }
376
609
 
377
610
  // Highlight the active link temporarily
378
- activeLink.classList.add('ring-2', 'ring-blue-500', 'ring-offset-2');
379
- setTimeout(() => {
380
- activeLink.classList.remove('ring-2', 'ring-blue-500', 'ring-offset-2');
381
- }, 1500);
611
+ activeLink.classList.remove('fade-out');
612
+ activeLink.classList.add('sidebar-highlight');
613
+ requestAnimationFrame(() => {
614
+ setTimeout(() => {
615
+ activeLink.classList.add('fade-out');
616
+ setTimeout(() => {
617
+ activeLink.classList.remove('sidebar-highlight', 'fade-out');
618
+ }, 10000);
619
+ }, 1000);
620
+ });
382
621
  }
383
622
  }
384
623
 
385
624
  function initPostsSidebarAutoReveal() {
386
625
  const postSidebars = document.querySelectorAll('details[data-sidebar="posts"]');
626
+
387
627
  postSidebars.forEach((sidebar) => {
388
628
  if (sidebar.dataset.revealBound === 'true') {
389
629
  return;
390
630
  }
391
631
  sidebar.dataset.revealBound = 'true';
632
+
633
+ // Reveal immediately if sidebar is already open
634
+ if (sidebar.open) {
635
+ revealInSidebar(sidebar);
636
+ }
637
+
392
638
  sidebar.addEventListener('toggle', () => {
393
639
  if (!sidebar.open) {
394
640
  return;
@@ -404,6 +650,214 @@ function initFolderChevronState(rootElement = document) {
404
650
  });
405
651
  }
406
652
 
653
+ function initSearchPlaceholderCycle(rootElement = document) {
654
+ const inputs = rootElement.querySelectorAll('input[data-placeholder-cycle]');
655
+ inputs.forEach((input) => {
656
+ if (input.dataset.placeholderCycleBound === 'true') {
657
+ return;
658
+ }
659
+ input.dataset.placeholderCycleBound = 'true';
660
+ const primary = input.dataset.placeholderPrimary || input.getAttribute('placeholder') || '';
661
+ const alt = input.dataset.placeholderAlt || '';
662
+ if (!alt) {
663
+ return;
664
+ }
665
+ let showAlt = false;
666
+ setInterval(() => {
667
+ if (input.value) {
668
+ return;
669
+ }
670
+ showAlt = !showAlt;
671
+ input.setAttribute('placeholder', showAlt ? alt : primary);
672
+ }, 10000);
673
+ });
674
+ }
675
+
676
+ function initCodeBlockCopyButtons(rootElement = document) {
677
+ const buttons = rootElement.querySelectorAll('.code-copy-button');
678
+ buttons.forEach((button) => {
679
+ if (button.dataset.copyBound === 'true') {
680
+ return;
681
+ }
682
+ button.dataset.copyBound = 'true';
683
+ button.addEventListener('click', () => {
684
+ const container = button.closest('.code-block');
685
+ const codeEl = container ? container.querySelector('pre > code') : null;
686
+ if (!codeEl) {
687
+ return;
688
+ }
689
+ const text = codeEl.innerText || codeEl.textContent || '';
690
+ const done = () => {
691
+ button.classList.add('is-copied');
692
+ setTimeout(() => button.classList.remove('is-copied'), 1200);
693
+ };
694
+ if (navigator.clipboard && window.isSecureContext) {
695
+ navigator.clipboard.writeText(text).then(done).catch(() => {
696
+ const textarea = document.createElement('textarea');
697
+ textarea.value = text;
698
+ textarea.setAttribute('readonly', '');
699
+ textarea.style.position = 'absolute';
700
+ textarea.style.left = '-9999px';
701
+ document.body.appendChild(textarea);
702
+ textarea.select();
703
+ document.execCommand('copy');
704
+ document.body.removeChild(textarea);
705
+ done();
706
+ });
707
+ } else {
708
+ const textarea = document.createElement('textarea');
709
+ textarea.value = text;
710
+ textarea.setAttribute('readonly', '');
711
+ textarea.style.position = 'absolute';
712
+ textarea.style.left = '-9999px';
713
+ document.body.appendChild(textarea);
714
+ textarea.select();
715
+ document.execCommand('copy');
716
+ document.body.removeChild(textarea);
717
+ done();
718
+ }
719
+ });
720
+ });
721
+ }
722
+
723
+ function initPostsSearchPersistence(rootElement = document) {
724
+ const input = rootElement.querySelector('.posts-search-block input[type="search"][name="q"]');
725
+ const results = rootElement.querySelector('.posts-search-results');
726
+ if (!input || !results) {
727
+ return;
728
+ }
729
+ if (input.dataset.searchPersistenceBound === 'true') {
730
+ return;
731
+ }
732
+ input.dataset.searchPersistenceBound = 'true';
733
+ const termKey = 'bloggy:postsSearchTerm';
734
+ const resultsKey = 'bloggy:postsSearchResults';
735
+ const enhanceGatherLink = () => {
736
+ const gatherLink = results.querySelector('a[href^="/search/gather"]');
737
+ if (!gatherLink) {
738
+ return;
739
+ }
740
+ const href = gatherLink.getAttribute('href');
741
+ if (!href) {
742
+ return;
743
+ }
744
+ gatherLink.setAttribute('hx_get', href);
745
+ gatherLink.setAttribute('hx_target', '#main-content');
746
+ gatherLink.setAttribute('hx_push_url', 'true');
747
+ gatherLink.setAttribute('hx_swap', 'outerHTML show:window:top settle:0.1s');
748
+ };
749
+ let storedTerm = '';
750
+ let storedResults = null;
751
+ try {
752
+ storedTerm = localStorage.getItem(termKey) || '';
753
+ storedResults = localStorage.getItem(resultsKey);
754
+ } catch (err) {
755
+ storedTerm = '';
756
+ storedResults = null;
757
+ }
758
+ if (storedTerm && !input.value) {
759
+ input.value = storedTerm;
760
+ }
761
+ if (storedResults && input.value) {
762
+ try {
763
+ const payload = JSON.parse(storedResults);
764
+ if (payload && payload.term === input.value && payload.html) {
765
+ results.innerHTML = payload.html;
766
+ enhanceGatherLink();
767
+ }
768
+ } catch (err) {
769
+ // Ignore malformed cached payloads.
770
+ }
771
+ }
772
+ const persistTerm = () => {
773
+ try {
774
+ if (input.value) {
775
+ localStorage.setItem(termKey, input.value);
776
+ } else {
777
+ localStorage.removeItem(termKey);
778
+ localStorage.removeItem(resultsKey);
779
+ }
780
+ } catch (err) {
781
+ // Ignore storage failures.
782
+ }
783
+ };
784
+ input.addEventListener('input', persistTerm);
785
+ const fetchResults = (query) => {
786
+ return fetch(`/_sidebar/posts/search?q=${query}`)
787
+ .then((response) => response.text())
788
+ .then((html) => {
789
+ results.innerHTML = html;
790
+ enhanceGatherLink();
791
+ try {
792
+ localStorage.setItem(resultsKey, JSON.stringify({
793
+ term: input.value,
794
+ html: results.innerHTML
795
+ }));
796
+ } catch (err) {
797
+ // Ignore storage failures.
798
+ }
799
+ })
800
+ .catch(() => {});
801
+ };
802
+ document.body.addEventListener('htmx:afterSwap', (event) => {
803
+ if (event.target !== results) {
804
+ return;
805
+ }
806
+ enhanceGatherLink();
807
+ try {
808
+ localStorage.setItem(resultsKey, JSON.stringify({
809
+ term: input.value,
810
+ html: results.innerHTML
811
+ }));
812
+ } catch (err) {
813
+ // Ignore storage failures.
814
+ }
815
+ });
816
+ if (input.value) {
817
+ const query = encodeURIComponent(input.value);
818
+ if (window.htmx && typeof window.htmx.ajax === 'function') {
819
+ window.htmx.ajax('GET', `/_sidebar/posts/search?q=${query}`, { target: results, swap: 'innerHTML' });
820
+ } else {
821
+ fetchResults(query);
822
+ }
823
+ }
824
+ }
825
+
826
+ function initSearchClearButtons(rootElement = document) {
827
+ const blocks = rootElement.querySelectorAll('.posts-search-block');
828
+ blocks.forEach((block) => {
829
+ const input = block.querySelector('input[type="search"][name="q"]');
830
+ const button = block.querySelector('.posts-search-clear-button');
831
+ const results = block.querySelector('.posts-search-results');
832
+ if (!input || !button) {
833
+ return;
834
+ }
835
+ if (button.dataset.clearBound === 'true') {
836
+ return;
837
+ }
838
+ button.dataset.clearBound = 'true';
839
+ const updateVisibility = () => {
840
+ button.style.opacity = input.value ? '1' : '0';
841
+ button.style.pointerEvents = input.value ? 'auto' : 'none';
842
+ };
843
+ updateVisibility();
844
+ input.addEventListener('input', updateVisibility);
845
+ button.addEventListener('click', () => {
846
+ input.value = '';
847
+ input.dispatchEvent(new Event('input', { bubbles: true }));
848
+ if (results) {
849
+ results.innerHTML = '';
850
+ }
851
+ try {
852
+ localStorage.removeItem('bloggy:postsSearchTerm');
853
+ localStorage.removeItem('bloggy:postsSearchResults');
854
+ } catch (err) {
855
+ // Ignore storage failures.
856
+ }
857
+ });
858
+ });
859
+ }
860
+
407
861
  document.addEventListener('toggle', (event) => {
408
862
  const details = event.target;
409
863
  if (!(details instanceof HTMLDetailsElement)) {
@@ -431,17 +885,27 @@ function updateActivePostLink() {
431
885
  }
432
886
 
433
887
  // Update active TOC link based on scroll position
888
+ let lastActiveTocAnchor = null;
434
889
  function updateActiveTocLink() {
435
890
  const headings = document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]');
436
891
  const tocLinks = document.querySelectorAll('.toc-link');
437
892
 
438
893
  let activeHeading = null;
894
+ let nearestBelow = null;
895
+ let nearestBelowTop = Infinity;
896
+ const offset = 140;
439
897
  headings.forEach(heading => {
440
898
  const rect = heading.getBoundingClientRect();
441
- if (rect.top <= 100) {
899
+ if (rect.top <= offset) {
442
900
  activeHeading = heading;
901
+ } else if (rect.top < nearestBelowTop) {
902
+ nearestBelowTop = rect.top;
903
+ nearestBelow = heading;
443
904
  }
444
905
  });
906
+ if (!activeHeading && nearestBelow) {
907
+ activeHeading = nearestBelow;
908
+ }
445
909
 
446
910
  tocLinks.forEach(link => {
447
911
  const anchor = link.getAttribute('data-anchor');
@@ -451,6 +915,14 @@ function updateActiveTocLink() {
451
915
  link.classList.remove('bg-blue-50', 'dark:bg-blue-900/20', 'text-blue-600', 'dark:text-blue-400', 'font-semibold');
452
916
  }
453
917
  });
918
+
919
+ const activeId = activeHeading ? activeHeading.id : null;
920
+ if (activeId && activeId !== lastActiveTocAnchor) {
921
+ document.querySelectorAll(`.toc-link[data-anchor="${activeId}"]`).forEach(link => {
922
+ link.scrollIntoView({ block: 'nearest' });
923
+ });
924
+ lastActiveTocAnchor = activeId;
925
+ }
454
926
  }
455
927
 
456
928
  // Listen for scroll events to update active TOC link
@@ -465,16 +937,71 @@ window.addEventListener('scroll', () => {
465
937
  }
466
938
  });
467
939
 
940
+ // Sync TOC highlight on hash changes and TOC clicks
941
+ window.addEventListener('hashchange', () => {
942
+ requestAnimationFrame(updateActiveTocLink);
943
+ });
944
+
945
+ document.addEventListener('click', (event) => {
946
+ const link = event.target.closest('.toc-link');
947
+ if (!link) {
948
+ return;
949
+ }
950
+ const anchor = link.getAttribute('data-anchor');
951
+ if (!anchor) {
952
+ return;
953
+ }
954
+ requestAnimationFrame(() => {
955
+ document.querySelectorAll('.toc-link').forEach(item => {
956
+ item.classList.toggle(
957
+ 'bg-blue-50',
958
+ item.getAttribute('data-anchor') === anchor
959
+ );
960
+ item.classList.toggle(
961
+ 'dark:bg-blue-900/20',
962
+ item.getAttribute('data-anchor') === anchor
963
+ );
964
+ item.classList.toggle(
965
+ 'text-blue-600',
966
+ item.getAttribute('data-anchor') === anchor
967
+ );
968
+ item.classList.toggle(
969
+ 'dark:text-blue-400',
970
+ item.getAttribute('data-anchor') === anchor
971
+ );
972
+ item.classList.toggle(
973
+ 'font-semibold',
974
+ item.getAttribute('data-anchor') === anchor
975
+ );
976
+ });
977
+ lastActiveTocAnchor = anchor;
978
+ updateActiveTocLink();
979
+ });
980
+ });
981
+
468
982
  // Re-run mermaid on HTMX content swaps
469
- document.body.addEventListener('htmx:afterSwap', function() {
983
+ document.body.addEventListener('htmx:afterSwap', function(event) {
984
+ mermaidDebugSnapshot('before mermaid.run (htmx:afterSwap)');
985
+ document.querySelectorAll('.mermaid-wrapper').forEach(wrapper => {
986
+ if (!wrapper.id) {
987
+ return;
988
+ }
989
+ // HTMX swaps can trigger a mermaid re-run that replaces SVGs.
990
+ // Clear interaction state so we always re-bind after mermaid.run().
991
+ delete mermaidStates[wrapper.id];
992
+ delete wrapper.dataset.mermaidInteractive;
993
+ });
470
994
  mermaid.run().then(() => {
471
- setTimeout(initMermaidInteraction, 100);
995
+ mermaidDebugSnapshot('after mermaid.run (htmx:afterSwap)');
996
+ scheduleMermaidInteraction();
472
997
  });
473
998
  updateActivePostLink();
474
999
  updateActiveTocLink();
475
1000
  initMobileMenus(); // Reinitialize mobile menu handlers
476
1001
  initPostsSidebarAutoReveal();
477
1002
  initFolderChevronState();
1003
+ initSearchPlaceholderCycle(event.target || document);
1004
+ initCodeBlockCopyButtons(event.target || document);
478
1005
  });
479
1006
 
480
1007
  // Watch for theme changes and re-render mermaid diagrams
@@ -574,6 +1101,79 @@ function initMobileMenus() {
574
1101
  }
575
1102
  }
576
1103
 
1104
+ // Keyboard shortcuts for toggling sidebars
1105
+ function initKeyboardShortcuts() {
1106
+ // Prewarm the selectors to avoid lazy compilation delays
1107
+ const postsSidebars = document.querySelectorAll('details[data-sidebar="posts"]');
1108
+ const tocSidebar = document.querySelector('#toc-sidebar details');
1109
+
1110
+ document.addEventListener('keydown', (e) => {
1111
+ // Skip if user is typing in an input field
1112
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
1113
+ return;
1114
+ }
1115
+
1116
+ // Z: Toggle posts panel
1117
+ if (e.key === 'z' || e.key === 'Z') {
1118
+ e.preventDefault();
1119
+ const postsSidebars = document.querySelectorAll('details[data-sidebar="posts"]');
1120
+ postsSidebars.forEach(sidebar => {
1121
+ sidebar.open = !sidebar.open;
1122
+ });
1123
+ }
1124
+
1125
+ // X: Toggle TOC panel
1126
+ if (e.key === 'x' || e.key === 'X') {
1127
+ e.preventDefault();
1128
+ const tocSidebar = document.querySelector('#toc-sidebar details');
1129
+ if (tocSidebar) {
1130
+ tocSidebar.open = !tocSidebar.open;
1131
+ }
1132
+ }
1133
+ });
1134
+ }
1135
+
1136
+ function syncPdfFocusButtons(root = document) {
1137
+ const isFocused = document.body.classList.contains('pdf-focus');
1138
+ root.querySelectorAll('[data-pdf-focus-toggle]').forEach((button) => {
1139
+ const focusLabel = button.getAttribute('data-pdf-focus-label') || 'Focus PDF';
1140
+ const exitLabel = button.getAttribute('data-pdf-exit-label') || 'Exit focus';
1141
+ button.textContent = isFocused ? exitLabel : focusLabel;
1142
+ button.setAttribute('aria-pressed', isFocused ? 'true' : 'false');
1143
+ });
1144
+ }
1145
+
1146
+ function ensurePdfFocusState() {
1147
+ const hasPdfViewer = document.querySelector('.pdf-viewer') || document.querySelector('[data-pdf-focus-toggle]');
1148
+ if (!hasPdfViewer) {
1149
+ document.body.classList.remove('pdf-focus');
1150
+ }
1151
+ syncPdfFocusButtons(document);
1152
+ }
1153
+
1154
+ function initPdfFocusToggle() {
1155
+ document.addEventListener('click', (event) => {
1156
+ const button = event.target.closest('[data-pdf-focus-toggle]');
1157
+ if (!button) {
1158
+ return;
1159
+ }
1160
+ event.preventDefault();
1161
+ document.body.classList.toggle('pdf-focus');
1162
+ syncPdfFocusButtons(document);
1163
+ });
1164
+
1165
+ document.addEventListener('keydown', (event) => {
1166
+ if (event.key !== 'Escape') {
1167
+ return;
1168
+ }
1169
+ if (!document.body.classList.contains('pdf-focus')) {
1170
+ return;
1171
+ }
1172
+ document.body.classList.remove('pdf-focus');
1173
+ syncPdfFocusButtons(document);
1174
+ });
1175
+ }
1176
+
577
1177
  // Initialize on page load
578
1178
  document.addEventListener('DOMContentLoaded', () => {
579
1179
  updateActivePostLink();
@@ -581,4 +1181,22 @@ document.addEventListener('DOMContentLoaded', () => {
581
1181
  initMobileMenus();
582
1182
  initPostsSidebarAutoReveal();
583
1183
  initFolderChevronState();
1184
+ initKeyboardShortcuts();
1185
+ initPdfFocusToggle();
1186
+ initSearchPlaceholderCycle(document);
1187
+ initPostsSearchPersistence(document);
1188
+ initCodeBlockCopyButtons(document);
1189
+ initSearchClearButtons(document);
1190
+ ensurePdfFocusState();
1191
+ });
1192
+
1193
+ document.body.addEventListener('htmx:afterSwap', (event) => {
1194
+ if (!event.target) {
1195
+ return;
1196
+ }
1197
+ initSearchPlaceholderCycle(event.target);
1198
+ initPostsSearchPersistence(event.target);
1199
+ initCodeBlockCopyButtons(event.target);
1200
+ initSearchClearButtons(event.target);
1201
+ ensurePdfFocusState();
584
1202
  });