thibaud frere commited on
Commit
75d3b58
·
1 Parent(s): a5b2529

update image and readme

Browse files
README-latex-integration.md CHANGED
@@ -1,75 +1,72 @@
1
  # 📚 LaTeX to MDX Integration
2
 
3
- Cette intégration permet de convertir automatiquement des sources LaTeX en MDX pour Astro, avec surveillance en temps réel des changements.
4
 
5
- ## 🚀 Utilisation
6
 
7
- ### Mode Développement avec LaTeX Watch
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  ```bash
10
- # Démarre le serveur de développement avec surveillance LaTeX
11
  npm run dev:with-latex
12
  ```
13
 
14
- Cette commande :
15
- 1. ✅ Convertit initialement LaTeX → MDX
16
- 2. 🔄 Surveille `scripts/latex-to-mdx/input/` pour les changements
17
- 3. 🌐 Lance le serveur Astro sur http://localhost:4321
18
- 4. 📝 Régénère automatiquement `article.mdx` lors de modifications
19
 
20
- ### Mode Production
21
 
22
  ```bash
23
- # Build de production (convertit LaTeX puis build Astro)
24
  npm run build
25
  ```
26
 
27
- ### Scripts Individuels
28
 
29
  ```bash
30
- # Conversion unique LaTeX → MDX
31
  npm run latex:convert
32
 
33
- # Surveillance LaTeX uniquement
34
  npm run latex:watch
35
 
36
- # Développement Astro classique (sans LaTeX)
37
  npm run dev
38
  ```
39
 
40
- ## 📁 Structure LaTeX
41
-
42
- Place tes sources LaTeX dans :
43
- ```
44
- app/scripts/latex-to-mdx/input/
45
- ├── main.tex # Document principal
46
- ├── main.bib # Bibliographie
47
- ├── sections/ # Chapitres/sections
48
- │ ├── 01_introduction.tex
49
- │ ├── 02_methods.tex
50
- │ └── ...
51
- ├── figures/ # Images
52
- └── ... # Autres fichiers LaTeX
53
- ```
54
 
55
- ## ⚙️ Fonctionnalités
56
 
57
- - 🔄 **Conversion automatique** : 2 secondes par conversion
58
- - 📊 **Surveillance complète** : Tout le dossier `input/`
59
- - 🎯 **Intégration Docker** : Pandoc installé automatiquement
60
- - 🌐 **Hot reload** : Astro recharge automatiquement après conversion
61
- - 📝 **Logs colorés** : Différenciation LaTeX/Astro dans la console
62
 
63
  ## 🐳 Docker
64
 
65
- Le Dockerfile inclut maintenant :
66
- - Installation automatique de Pandoc
67
- - Conversion LaTeX → MDX avant le build Astro
68
- - Support complet de l'environnement LaTeX
69
 
70
  ## 🛠️ Debugging
71
 
72
- Si la conversion échoue :
73
- 1. Vérifier les logs LaTeX (en bleu)
74
- 2. Vérifier que Pandoc est installé
75
- 3. Tester la conversion manuelle : `npm run latex:convert`
 
1
  # 📚 LaTeX to MDX Integration
2
 
3
+ This space allows the automatic conversion of any latex content to MDX for Astro, with real time monitoring!
4
 
5
+ ## 🚀 Usage
6
 
7
+ ## 📁 LaTeX Structure to follow
8
+
9
+ Place your LaTeX sources as such :
10
+ ```
11
+ app/scripts/latex-to-mdx/input/
12
+ ├── main.tex # Main doc
13
+ ├── main.bib # Bibliography
14
+ ├── sections/ # Sections or chapters
15
+ │ ├── 01_introduction.tex
16
+ │ ├── 02_methods.tex
17
+ │ └── ...
18
+ ├── figures/ # Images
19
+ └── ... # Other LaTeX files
20
+ ```
21
+
22
+ Now you can start the development server!
23
+
24
+ ### Development mode with LaTeX Watch
25
 
26
  ```bash
 
27
  npm run dev:with-latex
28
  ```
29
 
30
+ This commands :
31
+ 1. ✅ Converts LaTeX → MDX
32
+ 2. 🔄 Monitors the contents of `scripts/latex-to-mdx/input/` for any change
33
+ 3. 🌐 Launches an Astro server on http://localhost:4321
34
+ 4. 📝 Regerenates `article.mdx` automatically when you do changes to the latex files!
35
 
36
+ ### Production Mode
37
 
38
  ```bash
 
39
  npm run build
40
  ```
41
 
42
+ ### Other individual scripts
43
 
44
  ```bash
45
+ # Converts LaTeX → MDX once
46
  npm run latex:convert
47
 
48
+ # Monitors the LaTeX repo only
49
  npm run latex:watch
50
 
51
+ # Runs Astro without looking on latex (to edit the markdown files directly for example)
52
  npm run dev
53
  ```
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
+ ## ⚙️ Features
57
 
58
+ - 🔄 **Automated conversion** : 2 seconds per conversion
59
+ - 📊 **Monitoring** : Of everything in `input/`
60
+ - 🌐 **Hot reload** : Astro reloads after each conversion
61
+ - 📝 **Colored logs** : To differenciate LaTeX/Astro
 
62
 
63
  ## 🐳 Docker
64
 
65
+ Everything runs in a Dockerfile, which includes the automatic installation of pandox, the conversion of Latex to MDX before the build, and supports the latex env!
 
 
 
66
 
67
  ## 🛠️ Debugging
68
 
69
+ If the conversion fails:
70
+ 1. Check the LaTeX logs (blue ones)
71
+ 2. Check if Pandoc is installed properly
72
+ 3. Check the manual conversion with `npm run latex:convert`
app/src/components/MultiImage.astro CHANGED
@@ -171,11 +171,14 @@ const gridColumns = getGridColumns();
171
  :global(.medium-zoom--opened) .multi-image-item {
172
  opacity: 0;
173
  z-index: calc(var(--z-base) - 1);
174
- transition: opacity 0.3s ease, z-index 0.3s ease;
 
 
175
  }
176
 
177
  /* L'image actuellement zoomée reste visible */
178
- :global(.medium-zoom--opened) .multi-image-item:has(:global(.medium-zoom--opened)) {
 
179
  opacity: 1;
180
  z-index: var(--z-overlay);
181
  }
@@ -255,44 +258,62 @@ const gridColumns = getGridColumns();
255
  </style>
256
 
257
  <script>
258
- // Enhanced medium-zoom integration for MultiImage
259
- document.addEventListener('DOMContentLoaded', () => {
260
- // Amélioration du comportement des MultiImage avec medium-zoom
261
- const multiImages = document.querySelectorAll('.multi-image');
262
-
263
- multiImages.forEach(multiImage => {
264
- const items = multiImage.querySelectorAll('.multi-image-item');
265
- const zoomableImages = multiImage.querySelectorAll('img[data-zoomable="1"]');
266
-
267
- zoomableImages.forEach(img => {
268
- img.addEventListener('click', () => {
269
- // Trouver l'item parent de l'image cliquée
270
- const activeItem = img.closest('.multi-image-item');
271
-
272
- // Ajouter la classe pour le fallback :has()
273
- items.forEach(item => item.classList.remove('zoom-active'));
274
- if (activeItem) {
275
- activeItem.classList.add('zoom-active');
276
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  });
278
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  });
280
-
281
- // Nettoyer les classes lors de la fermeture du zoom
282
- document.addEventListener('click', (e) => {
283
- if (e.target.classList.contains('medium-zoom-overlay')) {
284
- // Zoom fermé, nettoyer les classes
285
- document.querySelectorAll('.multi-image-item.zoom-active')
286
- .forEach(item => item.classList.remove('zoom-active'));
287
- }
288
- });
289
-
290
- // Écouter les événements clavier pour fermer le zoom
291
- document.addEventListener('keydown', (e) => {
292
- if (e.key === 'Escape') {
293
- document.querySelectorAll('.multi-image-item.zoom-active')
294
- .forEach(item => item.classList.remove('zoom-active'));
295
- }
296
- });
297
- });
298
  </script>
 
171
  :global(.medium-zoom--opened) .multi-image-item {
172
  opacity: 0;
173
  z-index: calc(var(--z-base) - 1);
174
+ transition:
175
+ opacity 0.3s ease,
176
+ z-index 0.3s ease;
177
  }
178
 
179
  /* L'image actuellement zoomée reste visible */
180
+ :global(.medium-zoom--opened)
181
+ .multi-image-item:has(:global(.medium-zoom--opened)) {
182
  opacity: 1;
183
  z-index: var(--z-overlay);
184
  }
 
258
  </style>
259
 
260
  <script>
261
+ // Enhanced medium-zoom integration for MultiImage
262
+ document.addEventListener("DOMContentLoaded", () => {
263
+ // Amélioration du comportement des MultiImage avec medium-zoom
264
+ const multiImages = document.querySelectorAll(".multi-image");
265
+
266
+ multiImages.forEach((multiImage) => {
267
+ const items = multiImage.querySelectorAll(".multi-image-item");
268
+ const zoomableImages = multiImage.querySelectorAll(
269
+ 'img[data-zoomable="1"]',
270
+ );
271
+
272
+ zoomableImages.forEach((img) => {
273
+ img.addEventListener("click", () => {
274
+ // Trouver l'item parent de l'image cliquée et le ri-root
275
+ const activeItem = img.closest(".multi-image-item");
276
+ const riRoot = img.closest(".ri-root");
277
+
278
+ // Nettoyer TOUS les zoom-active (MultiImage items et ResponsiveImage)
279
+ document
280
+ .querySelectorAll(
281
+ ".multi-image-item.zoom-active, .ri-root.zoom-active",
282
+ )
283
+ .forEach((el) => el.classList.remove("zoom-active"));
284
+
285
+ // Ajouter zoom-active aux éléments actifs
286
+ if (activeItem) {
287
+ activeItem.classList.add("zoom-active");
288
+ }
289
+ if (riRoot) {
290
+ riRoot.classList.add("zoom-active");
291
+ }
292
+ });
293
  });
294
  });
295
+
296
+ // Nettoyer TOUTES les classes lors de la fermeture du zoom
297
+ document.addEventListener("click", (e) => {
298
+ if (e.target.classList.contains("medium-zoom-overlay")) {
299
+ // Zoom fermé, nettoyer toutes les classes zoom-active
300
+ document
301
+ .querySelectorAll(
302
+ ".multi-image-item.zoom-active, .ri-root.zoom-active",
303
+ )
304
+ .forEach((item) => item.classList.remove("zoom-active"));
305
+ }
306
+ });
307
+
308
+ // Écouter les événements clavier pour fermer le zoom
309
+ document.addEventListener("keydown", (e) => {
310
+ if (e.key === "Escape") {
311
+ document
312
+ .querySelectorAll(
313
+ ".multi-image-item.zoom-active, .ri-root.zoom-active",
314
+ )
315
+ .forEach((item) => item.classList.remove("zoom-active"));
316
+ }
317
+ });
318
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  </script>
app/src/components/ResponsiveImage.astro CHANGED
@@ -1,6 +1,6 @@
1
  ---
2
  // @ts-ignore - types provided by Astro at runtime
3
- import { Image } from 'astro:assets';
4
 
5
  interface Props {
6
  /** Source image imported via astro:assets */
@@ -29,73 +29,148 @@ interface Props {
29
  [key: string]: any;
30
  }
31
 
32
- const { caption, figureClass, zoomable, downloadable, downloadName, downloadSrc, linkHref, linkTarget, linkRel, ...imgProps } = Astro.props as Props;
33
- const hasCaptionSlot = Astro.slots.has('caption');
34
- const hasCaption = hasCaptionSlot || (typeof caption === 'string' && caption.length > 0);
 
 
 
 
 
 
 
 
 
 
 
 
35
  const uid = `ri_${Math.random().toString(36).slice(2)}`;
36
- const dataZoomable = (zoomable === true || (imgProps as any)['data-zoomable']) ? '1' : undefined;
37
- const dataDownloadable = (downloadable === true || (imgProps as any)['data-downloadable']) ? '1' : undefined;
38
- const hasLink = typeof linkHref === 'string' && linkHref.length > 0;
39
- const resolvedTarget = hasLink ? (linkTarget || '_blank') : undefined;
40
- const resolvedRel = hasLink ? (linkRel || 'noopener noreferrer') : undefined;
 
 
 
 
41
  ---
 
42
  <div class="ri-root" data-ri-root={uid}>
43
- {hasCaption ? (
44
- <figure class={(figureClass || '') + (dataDownloadable ? ' has-dl-btn' : '')}>
45
- {dataDownloadable ? (
46
- <span class="img-dl-wrap">
47
- {hasLink ? (
48
- <a class="ri-link" href={linkHref} target={resolvedTarget} rel={resolvedRel}>
49
- <Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} />
50
- </a>
51
- ) : (
52
- <Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} />
53
- )}
54
- <button type="button" class="button button--ghost img-dl-btn" aria-label="Download image" title={downloadName ? `Download ${downloadName}` : 'Download image'}>
55
- <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 16c-.26 0-.52-.11-.71-.29l-5-5a1 1 0 0 1 1.42-1.42L11 12.59V4a1 1 0 1 1 2 0v8.59l3.29-3.3a1 1 0 1 1 1.42 1.42l-5 5c-.19.18-.45.29-.71.29zM5 20a1 1 0 1 1 0-2h14a1 1 0 1 1 0 2H5z"/></svg>
56
- </button>
57
- </span>
58
- ) : (
59
- hasLink ? (
60
- <a class="ri-link" href={linkHref} target={resolvedTarget} rel={resolvedRel}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  <Image {...imgProps} data-zoomable={dataZoomable} />
62
  </a>
63
  ) : (
64
  <Image {...imgProps} data-zoomable={dataZoomable} />
65
- )
66
- )}
67
- <figcaption>
68
- {hasCaptionSlot ? (
69
- <slot name="caption" />
70
- ) : (
71
- caption && <span set:html={caption} />
72
  )}
73
- </figcaption>
74
- </figure>
75
- ) : (
76
- dataDownloadable ? (
 
 
 
 
 
77
  <span class="img-dl-wrap">
78
  {hasLink ? (
79
- <a class="ri-link" href={linkHref} target={resolvedTarget} rel={resolvedRel}>
80
- <Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} />
 
 
 
 
 
 
 
 
 
 
 
81
  </a>
82
  ) : (
83
- <Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} />
 
 
 
 
 
 
84
  )}
85
- <button type="button" class="button button--ghost img-dl-btn" aria-label="Download image" title={downloadName ? `Download ${downloadName}` : 'Download image'}>
86
- <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 16c-.26 0-.52-.11-.71-.29l-5-5a1 1 0 0 1 1.42-1.42L11 12.59V4a1 1 0 1 1 2 0v8.59l3.29-3.3a1 1 0 1 1 1.42 1.42l-5 5c-.19.18-.45.29-.71.29zM5 20a1 1 0 1 1 0-2h14a1 1 0 1 1 0 2H5z"/></svg>
 
 
 
 
 
 
 
87
  </button>
88
  </span>
89
- ) : (
90
- hasLink ? (
91
- <a class="ri-link" href={linkHref} target={resolvedTarget} rel={resolvedRel}>
92
- <Image {...imgProps} data-zoomable={dataZoomable} />
93
- </a>
94
- ) : (
 
95
  <Image {...imgProps} data-zoomable={dataZoomable} />
96
- )
 
 
97
  )
98
- )}
99
  </div>
100
 
101
  <script is:inline>
@@ -103,7 +178,12 @@ const resolvedRel = hasLink ? (linkRel || 'noopener noreferrer') : undefined;
103
  const scriptEl = document.currentScript;
104
  const root = scriptEl ? scriptEl.previousElementSibling : null;
105
  if (!root) return;
106
- const img = (root.tagName === 'IMG' ? root : (root.querySelector ? root.querySelector('img') : null));
 
 
 
 
 
107
  if (!img) return;
108
 
109
  // medium-zoom integration scoped to this image only
@@ -112,99 +192,167 @@ const resolvedRel = hasLink ? (linkRel || 'noopener noreferrer') : undefined;
112
  if (window.mediumZoom) return cb();
113
  const retry = () => {
114
  // @ts-ignore
115
- if (window.mediumZoom) cb(); else setTimeout(retry, 30);
 
116
  };
117
  retry();
118
  };
119
 
120
  const initZoomIfNeeded = () => {
121
- if (img.getAttribute('data-zoomable') !== '1') return;
122
- const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
123
- const background = isDark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)';
 
124
  ensureMediumZoomReady(() => {
125
  // @ts-ignore
126
- const instance = window.mediumZoom ? window.mediumZoom(img, { background, margin: 24, scrollOffset: 0 }) : null;
 
 
127
  if (!instance) return;
128
  let onScrollLike;
129
  const attachCloseOnScroll = () => {
130
  if (onScrollLike) return;
131
- onScrollLike = () => { try { instance.close && instance.close(); } catch {} };
132
- window.addEventListener('wheel', onScrollLike, { passive: true });
133
- window.addEventListener('touchmove', onScrollLike, { passive: true });
134
- window.addEventListener('scroll', onScrollLike, { passive: true });
 
 
 
 
135
  };
136
  const detachCloseOnScroll = () => {
137
  if (!onScrollLike) return;
138
- window.removeEventListener('wheel', onScrollLike);
139
- window.removeEventListener('touchmove', onScrollLike);
140
- window.removeEventListener('scroll', onScrollLike);
141
  onScrollLike = null;
142
  };
143
- try { instance.on && instance.on('open', attachCloseOnScroll); } catch {}
144
- try { instance.on && instance.on('close', detachCloseOnScroll); } catch {}
 
 
 
 
145
  const themeObserver = new MutationObserver(() => {
146
- const dark = document.documentElement.getAttribute('data-theme') === 'dark';
147
- try { instance.update && instance.update({ background: dark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)' }); } catch {}
 
 
 
 
 
 
 
 
 
 
148
  });
149
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  });
151
  };
152
 
153
  // Download button handler
154
- const dlBtn = (root.querySelector ? root.querySelector('.img-dl-btn') : null);
155
  if (dlBtn) {
156
- dlBtn.addEventListener('click', async (ev) => {
157
  try {
158
  ev.preventDefault();
159
  ev.stopPropagation();
160
  const pickHrefAndName = () => {
161
- const current = img.currentSrc || img.src || '';
162
- let href = img.getAttribute('data-download-src') || current;
163
  const deriveName = () => {
164
  try {
165
  const u = new URL(current, location.href);
166
- const rawHref = u.searchParams.get('href');
167
- const candidate = rawHref ? decodeURIComponent(rawHref) : u.pathname;
168
- const last = String(candidate).split('/').pop() || '';
169
- const base = last.split('?')[0].split('#')[0];
170
- const m = base.match(/^(.+?\.(?:png|jpe?g|webp|avif|gif|svg))(?:[._-].*)?$/i);
 
 
 
 
171
  if (m && m[1]) return m[1];
172
- return base || 'image';
173
- } catch { return 'image'; }
 
 
174
  };
175
- const name = img.getAttribute('data-download-name') || deriveName();
176
  return { href, name };
177
  };
178
  const picked = pickHrefAndName();
179
- const res = await fetch(picked.href, { credentials: 'same-origin' });
180
  const blob = await res.blob();
181
  const objectUrl = URL.createObjectURL(blob);
182
- const tmp = document.createElement('a');
183
  tmp.href = objectUrl;
184
- tmp.download = picked.name || 'image';
185
- tmp.target = '_self';
186
- tmp.rel = 'noopener';
187
- tmp.style.display = 'none';
188
  document.body.appendChild(tmp);
189
  tmp.click();
190
- setTimeout(() => { URL.revokeObjectURL(objectUrl); tmp.remove(); }, 1000);
 
 
 
191
  } catch {}
192
  });
193
  }
194
 
195
- if (document.readyState === 'complete') initZoomIfNeeded();
196
- else window.addEventListener('load', initZoomIfNeeded, { once: true });
 
 
 
197
  })();
198
  </script>
199
 
200
-
201
  <style>
202
-
203
- figure { margin: var(--block-spacing-y) 0; }
204
- figcaption { text-align: left; font-size: 0.9rem; color: var(--muted-color); margin-top: 6px; }
205
- figcaption { background: var(--page-bg); position: relative; z-index: var(--z-elevated); display: block; width: 100%; }
206
- .image-credit { display: block; margin-top: 4px; font-size: 12px; color: var(--muted-color); }
207
- .image-credit a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
 
209
  /* Zoomable overlay container (if used by any lightbox implementation) */
210
  [data-zoom-overlay],
@@ -215,16 +363,30 @@ const resolvedRel = hasLink ? (linkRel || 'noopener noreferrer') : undefined;
215
  }
216
 
217
  /* Download link inside figures */
218
- figure .download-link { position: relative; z-index: var(--z-elevated); }
 
 
 
219
 
220
- /* Opt-in zoomable images */
221
- img[data-zoomable] { cursor: zoom-in; }
222
- .medium-zoom--opened img[data-zoomable] { cursor: zoom-out; }
 
 
 
 
223
 
224
  /* Download button for img[data-downloadable] */
225
- figure.has-dl-btn { position: relative; }
226
- .dl-host { position: relative; }
227
- .img-dl-wrap { position: relative; display: inline-block; }
 
 
 
 
 
 
 
228
  .img-dl-btn {
229
  position: absolute;
230
  right: 8px;
@@ -236,12 +398,72 @@ const resolvedRel = hasLink ? (linkRel || 'noopener noreferrer') : undefined;
236
  border-radius: 6px;
237
  color: white;
238
  text-decoration: none;
239
- border: 1px solid rgba(255,255,255,0.25);
240
  z-index: var(--z-elevated);
241
  display: none;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  }
243
- .img-dl-btn svg { width: 18px; height: 18px; fill: currentColor; }
244
- .img-dl-wrap:hover .img-dl-btn { display: inline-flex; }
245
- [data-theme="dark"] .img-dl-btn { background: rgba(255,255,255,0.15); color: white; border-color: rgba(255,255,255,0.25); }
246
- [data-theme="dark"] .img-dl-btn:hover { background: rgba(255,255,255,0.25); }
247
- </style>
 
1
  ---
2
  // @ts-ignore - types provided by Astro at runtime
3
+ import { Image } from "astro:assets";
4
 
5
  interface Props {
6
  /** Source image imported via astro:assets */
 
29
  [key: string]: any;
30
  }
31
 
32
+ const {
33
+ caption,
34
+ figureClass,
35
+ zoomable,
36
+ downloadable,
37
+ downloadName,
38
+ downloadSrc,
39
+ linkHref,
40
+ linkTarget,
41
+ linkRel,
42
+ ...imgProps
43
+ } = Astro.props as Props;
44
+ const hasCaptionSlot = Astro.slots.has("caption");
45
+ const hasCaption =
46
+ hasCaptionSlot || (typeof caption === "string" && caption.length > 0);
47
  const uid = `ri_${Math.random().toString(36).slice(2)}`;
48
+ const dataZoomable =
49
+ zoomable === true || (imgProps as any)["data-zoomable"] ? "1" : undefined;
50
+ const dataDownloadable =
51
+ downloadable === true || (imgProps as any)["data-downloadable"]
52
+ ? "1"
53
+ : undefined;
54
+ const hasLink = typeof linkHref === "string" && linkHref.length > 0;
55
+ const resolvedTarget = hasLink ? linkTarget || "_blank" : undefined;
56
+ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
57
  ---
58
+
59
  <div class="ri-root" data-ri-root={uid}>
60
+ {
61
+ hasCaption ? (
62
+ <figure
63
+ class={(figureClass || "") + (dataDownloadable ? " has-dl-btn" : "")}
64
+ >
65
+ {dataDownloadable ? (
66
+ <span class="img-dl-wrap">
67
+ {hasLink ? (
68
+ <a
69
+ class="ri-link"
70
+ href={linkHref}
71
+ target={resolvedTarget}
72
+ rel={resolvedRel}
73
+ >
74
+ <Image
75
+ {...imgProps}
76
+ data-zoomable={dataZoomable}
77
+ data-downloadable={dataDownloadable}
78
+ data-download-name={downloadName}
79
+ data-download-src={downloadSrc}
80
+ />
81
+ </a>
82
+ ) : (
83
+ <Image
84
+ {...imgProps}
85
+ data-zoomable={dataZoomable}
86
+ data-downloadable={dataDownloadable}
87
+ data-download-name={downloadName}
88
+ data-download-src={downloadSrc}
89
+ />
90
+ )}
91
+ <button
92
+ type="button"
93
+ class="button img-dl-btn"
94
+ aria-label="Download image"
95
+ title={
96
+ downloadName ? `Download ${downloadName}` : "Download image"
97
+ }
98
+ >
99
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
100
+ <path d="M12 16c-.26 0-.52-.11-.71-.29l-5-5a1 1 0 0 1 1.42-1.42L11 12.59V4a1 1 0 1 1 2 0v8.59l3.29-3.3a1 1 0 1 1 1.42 1.42l-5 5c-.19.18-.45.29-.71.29zM5 20a1 1 0 1 1 0-2h14a1 1 0 1 1 0 2H5z" />
101
+ </svg>
102
+ </button>
103
+ </span>
104
+ ) : hasLink ? (
105
+ <a
106
+ class="ri-link"
107
+ href={linkHref}
108
+ target={resolvedTarget}
109
+ rel={resolvedRel}
110
+ >
111
  <Image {...imgProps} data-zoomable={dataZoomable} />
112
  </a>
113
  ) : (
114
  <Image {...imgProps} data-zoomable={dataZoomable} />
 
 
 
 
 
 
 
115
  )}
116
+ <figcaption>
117
+ {hasCaptionSlot ? (
118
+ <slot name="caption" />
119
+ ) : (
120
+ caption && <span set:html={caption} />
121
+ )}
122
+ </figcaption>
123
+ </figure>
124
+ ) : dataDownloadable ? (
125
  <span class="img-dl-wrap">
126
  {hasLink ? (
127
+ <a
128
+ class="ri-link"
129
+ href={linkHref}
130
+ target={resolvedTarget}
131
+ rel={resolvedRel}
132
+ >
133
+ <Image
134
+ {...imgProps}
135
+ data-zoomable={dataZoomable}
136
+ data-downloadable={dataDownloadable}
137
+ data-download-name={downloadName}
138
+ data-download-src={downloadSrc}
139
+ />
140
  </a>
141
  ) : (
142
+ <Image
143
+ {...imgProps}
144
+ data-zoomable={dataZoomable}
145
+ data-downloadable={dataDownloadable}
146
+ data-download-name={downloadName}
147
+ data-download-src={downloadSrc}
148
+ />
149
  )}
150
+ <button
151
+ type="button"
152
+ class="button img-dl-btn"
153
+ aria-label="Download image"
154
+ title={downloadName ? `Download ${downloadName}` : "Download image"}
155
+ >
156
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
157
+ <path d="M12 16c-.26 0-.52-.11-.71-.29l-5-5a1 1 0 0 1 1.42-1.42L11 12.59V4a1 1 0 1 1 2 0v8.59l3.29-3.3a1 1 0 1 1 1.42 1.42l-5 5c-.19.18-.45.29-.71.29zM5 20a1 1 0 1 1 0-2h14a1 1 0 1 1 0 2H5z" />
158
+ </svg>
159
  </button>
160
  </span>
161
+ ) : hasLink ? (
162
+ <a
163
+ class="ri-link"
164
+ href={linkHref}
165
+ target={resolvedTarget}
166
+ rel={resolvedRel}
167
+ >
168
  <Image {...imgProps} data-zoomable={dataZoomable} />
169
+ </a>
170
+ ) : (
171
+ <Image {...imgProps} data-zoomable={dataZoomable} />
172
  )
173
+ }
174
  </div>
175
 
176
  <script is:inline>
 
178
  const scriptEl = document.currentScript;
179
  const root = scriptEl ? scriptEl.previousElementSibling : null;
180
  if (!root) return;
181
+ const img =
182
+ root.tagName === "IMG"
183
+ ? root
184
+ : root.querySelector
185
+ ? root.querySelector("img")
186
+ : null;
187
  if (!img) return;
188
 
189
  // medium-zoom integration scoped to this image only
 
192
  if (window.mediumZoom) return cb();
193
  const retry = () => {
194
  // @ts-ignore
195
+ if (window.mediumZoom) cb();
196
+ else setTimeout(retry, 30);
197
  };
198
  retry();
199
  };
200
 
201
  const initZoomIfNeeded = () => {
202
+ if (img.getAttribute("data-zoomable") !== "1") return;
203
+ const isDark =
204
+ document.documentElement.getAttribute("data-theme") === "dark";
205
+ const background = isDark ? "rgba(0,0,0,.9)" : "rgba(0,0,0,.85)";
206
  ensureMediumZoomReady(() => {
207
  // @ts-ignore
208
+ const instance = window.mediumZoom
209
+ ? window.mediumZoom(img, { background, margin: 24, scrollOffset: 0 })
210
+ : null;
211
  if (!instance) return;
212
  let onScrollLike;
213
  const attachCloseOnScroll = () => {
214
  if (onScrollLike) return;
215
+ onScrollLike = () => {
216
+ try {
217
+ instance.close && instance.close();
218
+ } catch {}
219
+ };
220
+ window.addEventListener("wheel", onScrollLike, { passive: true });
221
+ window.addEventListener("touchmove", onScrollLike, { passive: true });
222
+ window.addEventListener("scroll", onScrollLike, { passive: true });
223
  };
224
  const detachCloseOnScroll = () => {
225
  if (!onScrollLike) return;
226
+ window.removeEventListener("wheel", onScrollLike);
227
+ window.removeEventListener("touchmove", onScrollLike);
228
+ window.removeEventListener("scroll", onScrollLike);
229
  onScrollLike = null;
230
  };
231
+ try {
232
+ instance.on && instance.on("open", attachCloseOnScroll);
233
+ } catch {}
234
+ try {
235
+ instance.on && instance.on("close", detachCloseOnScroll);
236
+ } catch {}
237
  const themeObserver = new MutationObserver(() => {
238
+ const dark =
239
+ document.documentElement.getAttribute("data-theme") === "dark";
240
+ try {
241
+ instance.update &&
242
+ instance.update({
243
+ background: dark ? "rgba(0,0,0,.9)" : "rgba(0,0,0,.85)",
244
+ });
245
+ } catch {}
246
+ });
247
+ themeObserver.observe(document.documentElement, {
248
+ attributes: true,
249
+ attributeFilter: ["data-theme"],
250
  });
251
+ });
252
+ };
253
+
254
+ // Gestion zoom global pour masquer autres ResponsiveImage
255
+ const setupGlobalZoomBehavior = () => {
256
+ img.addEventListener("click", () => {
257
+ if (img.getAttribute("data-zoomable") === "1") {
258
+ // Enlever zoom-active de tous les autres ri-root
259
+ document
260
+ .querySelectorAll(".ri-root.zoom-active")
261
+ .forEach((el) => el.classList.remove("zoom-active"));
262
+
263
+ // Ajouter zoom-active à cet ri-root
264
+ root.classList.add("zoom-active");
265
+ }
266
  });
267
  };
268
 
269
  // Download button handler
270
+ const dlBtn = root.querySelector ? root.querySelector(".img-dl-btn") : null;
271
  if (dlBtn) {
272
+ dlBtn.addEventListener("click", async (ev) => {
273
  try {
274
  ev.preventDefault();
275
  ev.stopPropagation();
276
  const pickHrefAndName = () => {
277
+ const current = img.currentSrc || img.src || "";
278
+ let href = img.getAttribute("data-download-src") || current;
279
  const deriveName = () => {
280
  try {
281
  const u = new URL(current, location.href);
282
+ const rawHref = u.searchParams.get("href");
283
+ const candidate = rawHref
284
+ ? decodeURIComponent(rawHref)
285
+ : u.pathname;
286
+ const last = String(candidate).split("/").pop() || "";
287
+ const base = last.split("?")[0].split("#")[0];
288
+ const m = base.match(
289
+ /^(.+?\.(?:png|jpe?g|webp|avif|gif|svg))(?:[._-].*)?$/i,
290
+ );
291
  if (m && m[1]) return m[1];
292
+ return base || "image";
293
+ } catch {
294
+ return "image";
295
+ }
296
  };
297
+ const name = img.getAttribute("data-download-name") || deriveName();
298
  return { href, name };
299
  };
300
  const picked = pickHrefAndName();
301
+ const res = await fetch(picked.href, { credentials: "same-origin" });
302
  const blob = await res.blob();
303
  const objectUrl = URL.createObjectURL(blob);
304
+ const tmp = document.createElement("a");
305
  tmp.href = objectUrl;
306
+ tmp.download = picked.name || "image";
307
+ tmp.target = "_self";
308
+ tmp.rel = "noopener";
309
+ tmp.style.display = "none";
310
  document.body.appendChild(tmp);
311
  tmp.click();
312
+ setTimeout(() => {
313
+ URL.revokeObjectURL(objectUrl);
314
+ tmp.remove();
315
+ }, 1000);
316
  } catch {}
317
  });
318
  }
319
 
320
+ // Setup comportement zoom
321
+ setupGlobalZoomBehavior();
322
+
323
+ if (document.readyState === "complete") initZoomIfNeeded();
324
+ else window.addEventListener("load", initZoomIfNeeded, { once: true });
325
  })();
326
  </script>
327
 
 
328
  <style>
329
+ figure {
330
+ margin: var(--block-spacing-y) 0;
331
+ }
332
+ figcaption {
333
+ text-align: left;
334
+ font-size: 0.9rem;
335
+ color: var(--muted-color);
336
+ margin-top: 6px;
337
+ }
338
+ figcaption {
339
+ background: var(--page-bg);
340
+ position: relative;
341
+ z-index: var(--z-elevated);
342
+ display: block;
343
+ width: 100%;
344
+ }
345
+ .image-credit {
346
+ display: block;
347
+ margin-top: 4px;
348
+ font-size: 12px;
349
+ color: var(--muted-color);
350
+ }
351
+ .image-credit a {
352
+ color: inherit;
353
+ text-decoration: underline;
354
+ text-underline-offset: 2px;
355
+ }
356
 
357
  /* Zoomable overlay container (if used by any lightbox implementation) */
358
  [data-zoom-overlay],
 
363
  }
364
 
365
  /* Download link inside figures */
366
+ figure .download-link {
367
+ position: relative;
368
+ z-index: var(--z-elevated);
369
+ }
370
 
371
+ /* Opt-in zoomable images */
372
+ img[data-zoomable] {
373
+ cursor: zoom-in;
374
+ }
375
+ .medium-zoom--opened img[data-zoomable] {
376
+ cursor: zoom-out;
377
+ }
378
 
379
  /* Download button for img[data-downloadable] */
380
+ figure.has-dl-btn {
381
+ position: relative;
382
+ }
383
+ .dl-host {
384
+ position: relative;
385
+ }
386
+ .img-dl-wrap {
387
+ position: relative;
388
+ display: inline-block;
389
+ }
390
  .img-dl-btn {
391
  position: absolute;
392
  right: 8px;
 
398
  border-radius: 6px;
399
  color: white;
400
  text-decoration: none;
401
+ border: 1px solid rgba(255, 255, 255, 0.25);
402
  z-index: var(--z-elevated);
403
  display: none;
404
+ background: var(--primary-color);
405
+ }
406
+
407
+ /* Quand une image est zoomée, cacher TOUS les ResponsiveImage de la page */
408
+ :global(.medium-zoom--opened) .ri-root {
409
+ opacity: 0;
410
+ z-index: calc(var(--z-base) - 1);
411
+ transition: opacity 0.3s ease;
412
+ }
413
+
414
+ /* L'image actuellement zoomée reste visible */
415
+ :global(.medium-zoom--opened) .ri-root:has(.medium-zoom--opened) {
416
+ opacity: 1;
417
+ z-index: var(--z-overlay);
418
+ }
419
+
420
+ /* Fallback pour navigateurs sans support :has() */
421
+ :global(.medium-zoom--opened) .ri-root.zoom-active {
422
+ opacity: 1 !important;
423
+ z-index: var(--z-overlay) !important;
424
+ }
425
+
426
+ /* Spécifiquement masquer bouton download et figcaption lors du zoom */
427
+ :global(.medium-zoom--opened) .img-dl-btn {
428
+ opacity: 0;
429
+ z-index: calc(var(--z-base) - 1);
430
+ transition: opacity 0.3s ease;
431
+ }
432
+
433
+ :global(.medium-zoom--opened) figcaption {
434
+ opacity: 0;
435
+ z-index: calc(var(--z-base) - 1);
436
+ transition: opacity 0.3s ease;
437
+ }
438
+
439
+ /* Même pour l'image zoomée active, masquer bouton et caption pour une expérience propre */
440
+ :global(.medium-zoom--opened) .ri-root.zoom-active .img-dl-btn {
441
+ opacity: 0;
442
+ z-index: calc(var(--z-base) - 1);
443
+ }
444
+
445
+ :global(.medium-zoom--opened) .ri-root.zoom-active figcaption {
446
+ opacity: 0;
447
+ z-index: calc(var(--z-base) - 1);
448
+ }
449
+ .img-dl-btn svg {
450
+ width: 18px;
451
+ height: 18px;
452
+ fill: currentColor;
453
+ }
454
+ .img-dl-wrap:hover .img-dl-btn {
455
+ display: inline-flex;
456
+ }
457
+ .img-dl-btn:hover {
458
+ background: var(--primary-color-hover);
459
+ }
460
+
461
+ [data-theme="dark"] .img-dl-btn {
462
+ background: var(--primary-color);
463
+ color: var(--on-primary);
464
+ border-color: var(--primary-color);
465
+ }
466
+ [data-theme="dark"] .img-dl-btn:hover {
467
+ background: var(--primary-color-hover);
468
  }
469
+ </style>
 
 
 
 
app/src/content/article.mdx CHANGED
The diff for this file is too large to render. See raw diff