bloggy 0.1.43__py3-none-any.whl → 0.2.5__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) {
@@ -390,11 +623,18 @@ function revealInSidebar(rootElement = document) {
390
623
 
391
624
  function initPostsSidebarAutoReveal() {
392
625
  const postSidebars = document.querySelectorAll('details[data-sidebar="posts"]');
626
+
393
627
  postSidebars.forEach((sidebar) => {
394
628
  if (sidebar.dataset.revealBound === 'true') {
395
629
  return;
396
630
  }
397
631
  sidebar.dataset.revealBound = 'true';
632
+
633
+ // Reveal immediately if sidebar is already open
634
+ if (sidebar.open) {
635
+ revealInSidebar(sidebar);
636
+ }
637
+
398
638
  sidebar.addEventListener('toggle', () => {
399
639
  if (!sidebar.open) {
400
640
  return;
@@ -410,6 +650,214 @@ function initFolderChevronState(rootElement = document) {
410
650
  });
411
651
  }
412
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
+
413
861
  document.addEventListener('toggle', (event) => {
414
862
  const details = event.target;
415
863
  if (!(details instanceof HTMLDetailsElement)) {
@@ -437,17 +885,27 @@ function updateActivePostLink() {
437
885
  }
438
886
 
439
887
  // Update active TOC link based on scroll position
888
+ let lastActiveTocAnchor = null;
440
889
  function updateActiveTocLink() {
441
890
  const headings = document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]');
442
891
  const tocLinks = document.querySelectorAll('.toc-link');
443
892
 
444
893
  let activeHeading = null;
894
+ let nearestBelow = null;
895
+ let nearestBelowTop = Infinity;
896
+ const offset = 140;
445
897
  headings.forEach(heading => {
446
898
  const rect = heading.getBoundingClientRect();
447
- if (rect.top <= 100) {
899
+ if (rect.top <= offset) {
448
900
  activeHeading = heading;
901
+ } else if (rect.top < nearestBelowTop) {
902
+ nearestBelowTop = rect.top;
903
+ nearestBelow = heading;
449
904
  }
450
905
  });
906
+ if (!activeHeading && nearestBelow) {
907
+ activeHeading = nearestBelow;
908
+ }
451
909
 
452
910
  tocLinks.forEach(link => {
453
911
  const anchor = link.getAttribute('data-anchor');
@@ -457,6 +915,14 @@ function updateActiveTocLink() {
457
915
  link.classList.remove('bg-blue-50', 'dark:bg-blue-900/20', 'text-blue-600', 'dark:text-blue-400', 'font-semibold');
458
916
  }
459
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
+ }
460
926
  }
461
927
 
462
928
  // Listen for scroll events to update active TOC link
@@ -471,16 +937,71 @@ window.addEventListener('scroll', () => {
471
937
  }
472
938
  });
473
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
+
474
982
  // Re-run mermaid on HTMX content swaps
475
- 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
+ });
476
994
  mermaid.run().then(() => {
477
- setTimeout(initMermaidInteraction, 100);
995
+ mermaidDebugSnapshot('after mermaid.run (htmx:afterSwap)');
996
+ scheduleMermaidInteraction();
478
997
  });
479
998
  updateActivePostLink();
480
999
  updateActiveTocLink();
481
1000
  initMobileMenus(); // Reinitialize mobile menu handlers
482
1001
  initPostsSidebarAutoReveal();
483
1002
  initFolderChevronState();
1003
+ initSearchPlaceholderCycle(event.target || document);
1004
+ initCodeBlockCopyButtons(event.target || document);
484
1005
  });
485
1006
 
486
1007
  // Watch for theme changes and re-render mermaid diagrams
@@ -580,6 +1101,79 @@ function initMobileMenus() {
580
1101
  }
581
1102
  }
582
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
+
583
1177
  // Initialize on page load
584
1178
  document.addEventListener('DOMContentLoaded', () => {
585
1179
  updateActivePostLink();
@@ -587,4 +1181,22 @@ document.addEventListener('DOMContentLoaded', () => {
587
1181
  initMobileMenus();
588
1182
  initPostsSidebarAutoReveal();
589
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();
590
1202
  });