pyfrilet 0.4.3 → 0.5.1

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.
package/README.md CHANGED
@@ -5,9 +5,7 @@ Il combine [p5.js](https://p5js.org/) (dessin 2D) et [Pyodide](https://pyodide.o
5
5
 
6
6
  ---
7
7
 
8
- ## Écrire un sketch Python
9
-
10
- Un sketch est un fichier HTML minimal avec son code Python embarqué.
8
+ ## Démarrage rapide
11
9
 
12
10
  ```html
13
11
  <!doctype html>
@@ -18,7 +16,7 @@ Un sketch est un fichier HTML minimal avec son code Python embarqué.
18
16
  <script src="https://cdn.jsdelivr.net/npm/pyfrilet@latest/pyfrilet.min.js"></script>
19
17
  </head>
20
18
  <body>
21
- <script type="text/python" data-sources="cdn">
19
+ <script type="text/python">
22
20
 
23
21
  from p5 import *
24
22
 
@@ -36,7 +34,11 @@ def draw():
36
34
  </html>
37
35
  ```
38
36
 
39
- C'est tout. pyfrilet se charge de démarrer Pyodide, de monter le module `p5`, et de lancer le sketch.
37
+ C'est tout. pyfrilet se charge de démarrer Pyodide, de monter le module `p5`, et de lancer le sketch. Les dépendances (p5.js, Pyodide, ACE) sont chargées depuis des CDN publics par défaut.
38
+
39
+ ---
40
+
41
+ ## Écrire un sketch Python
40
42
 
41
43
  ### API p5 disponible
42
44
 
@@ -98,6 +100,8 @@ Ces fonctions sont définies par l'utilisateur et appelées automatiquement par
98
100
  | `touchMoved()` | Contact tactile déplacé. Utile pour le multi-touch (pinch zoom…). |
99
101
  | `touchEnded()` | Fin de contact tactile. |
100
102
 
103
+ > **Limite de temps sur `draw()`** : si une exécution de `draw()` dépasse 200 ms, pyfrilet arrête le sketch et affiche une erreur. Cela protège le navigateur contre les boucles accidentellement bloquantes. Pour des calculs lourds, effectuer le travail en dehors de `draw()` (dans `setup()` ou via un état mis à jour progressivement).
104
+
101
105
  #### Fonctions pyfrilet (hors p5.js standard)
102
106
 
103
107
  | Fonction | Description |
@@ -215,12 +219,6 @@ def draw():
215
219
  image(img, 0, 0, width, height)
216
220
  ```
217
221
 
218
- `create_proxy` est nécessaire pour que Pyodide maintienne la référence à la fonction Python active entre les appels JS. `file.type` contient le type MIME (`"image/png"`…), `file.data` la data URL base64.
219
-
220
- ### Protection contre les boucles infinies
221
-
222
- Si `draw()` met plus de 200 ms à s'exécuter, pyfrilet arrête le sketch automatiquement et affiche un message d'erreur. Cela protège le navigateur contre les calculs trop lourds qui bloqueraient l'interface. Pour des traitements lents, il est recommandé de les découper sur plusieurs frames via `frameCount`.
223
-
224
222
  ---
225
223
 
226
224
  ### Note sur `smooth()` / `noSmooth()` et le texte
@@ -249,7 +247,7 @@ La barre de contrôle est collée en bas de l'écran.
249
247
  | ✏️ | Ouvre l'éditeur en **plein écran** ; referme si déjà ouvert |
250
248
  | 💾 | Télécharge la page en HTML autonome (voir ci-dessous) |
251
249
  | ⏺ | Démarre l'enregistrement WebM du canvas ; devient ⏹ pendant l'enregistrement — cliquer pour arrêter et télécharger |
252
- | ↻ | Réinitialise le code à la version d'origine (confirmation demandée). Un point orange apparaît sur le bouton quand le code a été modifié. |
250
+ | ↻ | Réinitialise le code de l'onglet actif à la version d'origine (confirmation demandée). Un point orange apparaît sur le bouton quand le code a été modifié. |
253
251
 
254
252
  ### Raccourcis clavier
255
253
 
@@ -258,50 +256,162 @@ La barre de contrôle est collée en bas de l'écran.
258
256
  | `Shift+Entrée` | Relance l'exécution (depuis l'éditeur ou depuis le sketch) |
259
257
  | `Échap` | Ouvre le tiroir si fermé, ferme si ouvert |
260
258
  | `Ctrl+S` | Sauvegarde le code dans le localStorage |
261
- | `Ctrl+R` | Réinitialise le code à la version d'origine (confirmation demandée) |
259
+ | `Ctrl+R` | Réinitialise l'onglet actif à la version d'origine (confirmation demandée) |
262
260
 
263
261
  Quand le tiroir se ferme, le focus clavier est automatiquement donné au canvas — les événements `keyPressed()` du sketch fonctionnent sans clic préalable.
264
262
 
265
- Le code est **sauvegardé automatiquement** dans le `localStorage` à chaque modification. Il est restauré à l'ouverture de la page.
263
+ Le code est **sauvegardé automatiquement** dans le `localStorage` à chaque modification (par onglet). Il est restauré à l'ouverture de la page.
266
264
 
267
265
  ### Télécharger
268
266
 
269
- Le bouton 💾 génère un fichier `sketch.html` autonome : il référence pyfrilet depuis jsDelivr et embarque le code Python tel qu'il est dans l'éditeur au moment du clic. Le fichier produit charge p5.js et Pyodide depuis le CDN et n'a besoin d'aucun serveur pour fonctionner.
267
+ Le bouton 💾 génère un fichier `sketch.html` autonome : il référence pyfrilet depuis jsDelivr et reconstruit tous les blocs `<script>` tels qu'ils sont dans l'éditeur au moment du clic, en préservant leur structure (onglets, blocs cachés, blocs en lecture seule). Le fichier produit charge p5.js et Pyodide depuis le CDN et n'a besoin d'aucun serveur pour fonctionner.
270
268
 
271
269
  ---
272
270
 
273
- ## Utiliser pyfrilet depuis le CDN
271
+ ## Onglets (utilisation avancée)
272
+
273
+ Quand une page contient plusieurs blocs de code, pyfrilet affiche une barre d'onglets dans l'éditeur. Chaque bloc est déclaré avec un `<script>` séparé.
274
274
 
275
- La façon la plus simple d'utiliser pyfrilet est de le charger depuis jsDelivr, sans rien installer :
275
+ Tous les blocs Python partagent le même namespace : les variables et fonctions définies dans un bloc sont visibles dans les autres.
276
+
277
+ ### Attributs disponibles
278
+
279
+ | Attribut | Effet |
280
+ |---|---|
281
+ | `data-tab="Nom"` | Crée un onglet visible avec ce libellé |
282
+ | `data-readonly` | L'onglet est visible mais non modifiable (cadenas affiché) |
283
+ | `data-hidden` | Le bloc est exécuté mais n'apparaît pas dans l'éditeur |
284
+ | `type="text/markdown"` | Le contenu est rendu en Markdown (non exécuté) |
285
+
286
+ ### Exemple : énoncé + code utilitaire + zone élève
276
287
 
277
288
  ```html
278
- <script src="https://cdn.jsdelivr.net/npm/pyfrilet@latest/pyfrilet.min.js"></script>
289
+ <script type="text/markdown" data-tab="Énoncé">
290
+ # Exercice
291
+
292
+ Complète la fonction `dessiner()` pour afficher un cercle rouge au centre du canvas.
293
+ </script>
294
+
295
+ <script type="text/python" data-tab="Utilitaires" data-readonly>
296
+ # Fonctions fournies — non modifiables
297
+ def rouge():
298
+ fill(255, 0, 0)
299
+ no_stroke()
300
+ </script>
301
+
302
+ <script type="text/python" data-tab="Ta solution">
303
+ from p5 import *
304
+
305
+ def setup():
306
+ size(400, 400)
307
+
308
+ def draw():
309
+ background(20)
310
+ # à toi de jouer !
311
+ </script>
279
312
  ```
280
313
 
281
- Pour épingler une version précise et éviter les surprises lors d'une montée de version :
314
+ ### Bloc caché
315
+
316
+ Un bloc `data-hidden` est concaténé au code exécuté mais invisible dans l'éditeur. Utile pour du code d'initialisation ou de vérification qu'on ne souhaite pas exposer :
282
317
 
283
318
  ```html
284
- <script src="https://cdn.jsdelivr.net/npm/pyfrilet@0.1.0/pyfrilet.min.js"></script>
319
+ <script type="text/python" data-hidden>
320
+ # Code invisible — exécuté en premier
321
+ PALETTE = ['#7aa2f7', '#e0af68', '#9ece6a']
322
+ </script>
323
+
324
+ <script type="text/python" data-tab="Sketch">
325
+ from p5 import *
326
+
327
+ def setup():
328
+ size(400, 400)
329
+
330
+ def draw():
331
+ background(20)
332
+ fill(PALETTE[frameCount // 60 % 3])
333
+ circle(200, 200, 100)
334
+ </script>
285
335
  ```
286
336
 
287
- > **Note** : `@latest` est mis en cache jusqu'à 7 jours par les navigateurs — une version épinglée est préférable dès qu'on destine un sketch à un public.
337
+ ### Onglets Markdown
338
+
339
+ Le contenu d'un onglet `type="text/markdown"` est rendu avec [marked](https://marked.js.org/) et supporte les formules mathématiques via [KaTeX](https://katex.org/) et les diagrammes via [Mermaid](https://mermaid.js.org/).
340
+
341
+ **Formules mathématiques (KaTeX) :**
342
+
343
+ | Syntaxe | Rendu |
344
+ |---|---|
345
+ | `$f(x) = x^2$` | Formule inline dans le texte |
346
+ | `$$\sum_{i=0}^{n} i = \frac{n(n+1)}{2}$$` | Bloc centré sur sa propre ligne |
347
+
348
+ **Diagrammes (Mermaid) :**
349
+
350
+ Un bloc de code avec le langage `mermaid` est rendu comme un diagramme SVG :
351
+
352
+ ````markdown
353
+ ```mermaid
354
+ graph TD
355
+ A[Départ] --> B{Condition}
356
+ B -->|oui| C[Résultat 1]
357
+ B -->|non| D[Résultat 2]
358
+ ```
359
+ ````
360
+
361
+ Mermaid supporte de nombreux types de diagrammes : `graph`, `sequenceDiagram`, `classDiagram`, `flowchart`, `gantt`, `pie`… Voir la [documentation Mermaid](https://mermaid.js.org/intro/) pour la syntaxe complète.
362
+
363
+ **Exemple combiné :**
364
+
365
+ ```html
366
+ <script type="text/markdown" data-tab="Cours">
367
+ # Algorithme de Dijkstra
368
+
369
+ Soit un graphe $G = (V, E)$ avec des poids $w(u, v) \geq 0$.
370
+
371
+ L'algorithme maintient un ensemble $S$ de sommets dont la distance
372
+ minimale depuis la source $s$ est connue. À chaque étape on extrait
373
+ le sommet $u \notin S$ qui minimise :
374
+
375
+ $$d(s, u) = \min_{v \in S} \left( d(s, v) + w(v, u) \right)$$
376
+
377
+ La complexité est $O((V + E) \log V)$ avec un tas binaire.
378
+
379
+ ```mermaid
380
+ graph LR
381
+ A((1)) -->|4| B((2))
382
+ A -->|1| C((3))
383
+ C -->|2| B
384
+ B -->|1| D((4))
385
+ ```
386
+ </script>
387
+ ```
388
+
389
+
390
+
391
+ Les blocs Python sont concaténés dans l'ordre du DOM avant exécution : blocs cachés et visibles sont traités ensemble, dans l'ordre où ils apparaissent dans le HTML. L'onglet actif dans l'éditeur n'influence pas l'exécution.
288
392
 
289
393
  ---
290
394
 
291
395
  ## Déploiement local (mode `vendor/`)
292
396
 
293
- Par défaut (`data-sources="cdn"`), pyfrilet charge p5.js, Pyodide et ACE depuis des CDN publics, ce qui nécessite une connexion internet. Pour un déploiement entièrement local — intranet, usage hors ligne, ou simplement pour ne pas dépendre de services tiers — on peut héberger les dépendances soi-même.
397
+ Par défaut, pyfrilet charge p5.js, Pyodide et ACE depuis des CDN publics. Pour un déploiement entièrement local — intranet, usage hors ligne, ou pour ne pas dépendre de services tiers — on peut héberger les dépendances soi-même.
294
398
 
295
- ### Utiliser une copie locale de pyfrilet.js
399
+ ### Configuration
296
400
 
297
- On remplace la balise jsDelivr par une référence locale :
401
+ `data-sources` et `data-vendor` se placent de préférence sur la balise `<script src="pyfrilet.js">` elle-même. Pour la rétrocompatibilité, ces attributs sont également acceptés sur le premier bloc `<script type="text/python">`.
298
402
 
299
403
  ```html
300
- <!-- au lieu de : <script src="https://cdn.jsdelivr.net/npm/pyfrilet@…/pyfrilet.min.js"></script> -->
404
+ <!-- Recommandé -->
405
+ <script src="pyfrilet.js" data-sources="local" data-vendor="vendor/"></script>
406
+
407
+ <!-- Rétrocompat (ancienne syntaxe) -->
301
408
  <script src="pyfrilet.js"></script>
409
+ <script type="text/python" data-sources="local" data-vendor="vendor/">
410
+
411
+ </script>
302
412
  ```
303
413
 
304
- Le fichier `pyfrilet.js` est alors servi depuis le même répertoire que la page HTML. C'est utile pour modifier pyfrilet lui-même, ou pour un déploiement sans accès internet.
414
+ `data-vendor` indique le chemin vers le dossier `vendor/` **relatif à la page HTML**. La valeur par défaut est `vendor/`.
305
415
 
306
416
  ### Structure de fichiers
307
417
 
@@ -316,6 +426,11 @@ mon-projet/
316
426
  ├── theme-monokai.min.js
317
427
  ├── ext-language_tools.min.js
318
428
  ├── ext-searchbox.min.js
429
+ ├── marked.min.js ← uniquement si onglets Markdown
430
+ ├── katex.min.css ← uniquement si onglets Markdown
431
+ ├── katex.min.js ← uniquement si onglets Markdown
432
+ ├── marked-katex-extension.js ← uniquement si onglets Markdown
433
+ ├── mermaid.min.js ← uniquement si onglets Markdown
319
434
  └── pyodide/
320
435
  ├── pyodide.js
321
436
  ├── pyodide.asm.wasm
@@ -339,25 +454,25 @@ https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/ext-language_tools.min.js
339
454
  https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/ext-searchbox.min.js
340
455
  ```
341
456
 
457
+ **marked.js + KaTeX + Mermaid** (uniquement si onglets Markdown)
458
+ ```
459
+ https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.0/marked.min.js
460
+ https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css
461
+ https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js
462
+ https://cdn.jsdelivr.net/npm/marked-katex-extension@5.1.1/lib/index.umd.js
463
+ https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js
464
+ ```
465
+ Renommer `index.umd.js` en `marked-katex-extension.js` dans le dossier vendor.
466
+
342
467
  **Pyodide** — télécharger l'archive complète depuis les releases GitHub :
343
468
  ```
344
469
  https://github.com/pyodide/pyodide/releases/tag/0.26.4
345
470
  ```
346
471
  Extraire le contenu dans `vendor/pyodide/`.
347
472
 
348
- ### Balise HTML
349
-
350
- ```html
351
- <script type="text/python" data-sources="local" data-vendor="vendor/">
352
- …code python…
353
- </script>
354
- ```
355
-
356
- `data-vendor` indique le chemin vers le dossier `vendor/` **relatif à la page HTML**. La valeur par défaut est `vendor/` si l'attribut est absent.
357
-
358
473
  ### Serveur local
359
474
 
360
- Les navigateurs bloquent le chargement de fichiers locaux (`file://`) pour des raisons de sécurité. Il faut donc un serveur HTTP minimal pour tester en local, même sans déploiement :
475
+ Les navigateurs bloquent le chargement de fichiers locaux (`file://`) pour des raisons de sécurité. Il faut un serveur HTTP minimal pour tester en local :
361
476
 
362
477
  ```bash
363
478
  # Python 3
@@ -373,10 +488,10 @@ Puis ouvrir `http://localhost:8000/mon-sketch.html`.
373
488
 
374
489
  ## Build et publication
375
490
 
376
- Le build est géré par `build.js` (Node.js + Terser). Il injecte automatiquement la version courante dans la constante `PYFRILET_CDN` du fichier source, puis génère `pyfrilet.min.js`.
491
+ Le build est géré par `build.js` (Node.js + Terser). Il concatène les fragments de `src/`, injecte CSS et HTML, puis génère `pyfrilet.js` (lisible) et `pyfrilet.min.js`.
377
492
 
378
493
  ```bash
379
- npm run build # génère pyfrilet.min.js
494
+ npm run build # génère pyfrilet.js + pyfrilet.min.js
380
495
  ```
381
496
 
382
497
  Le hook `prepublishOnly` dans `package.json` déclenche le build automatiquement avant chaque `npm publish`. Le flux complet d'une release :
@@ -391,8 +506,6 @@ npm publish # build automatique puis publication sur npm
391
506
  git push && git push --tags # pousser commits et tag sur Codeberg
392
507
  ```
393
508
 
394
- `npm version` incrémente le numéro de version selon les conventions semver : `patch` pour `z`, `minor` pour `y` (remet `z` à 0), `major` pour `x` (remet `y` et `z` à 0).
395
-
396
509
  ---
397
510
 
398
511
  ## Licence
@@ -414,20 +527,10 @@ pyfrilet ne contient aucun code de ces bibliothèques ; elles sont chargées sé
414
527
  | [p5.js](https://p5js.org/) | LGPL 2.1 |
415
528
  | [Pyodide](https://pyodide.org/) | MPL 2.0 |
416
529
  | [ACE editor](https://ace.c9.io/) | BSD 3-Clause |
417
-
418
- ### Contenu minimal d'un dépôt de distribution
419
-
420
- Pour distribuer un sketch sur un dépôt git de façon propre et conforme :
421
-
422
- ```
423
- mon-sketch/
424
- ├── LICENSE ← texte de la LGPL 2.1
425
- ├── README.md ← cette doc, ou un résumé
426
- ├── pyfrilet.js
427
- └── mon-sketch.html
428
- ```
429
-
430
- Les dépendances (`vendor/`) n'ont pas à être versionnées si on utilise le mode CDN. Si on veut un déploiement hors ligne, on les ajoute au dépôt en respectant leurs licences respectives — un fichier `NOTICE` listant les bibliothèques tierces et leurs licences est une bonne pratique.
530
+ | [marked](https://marked.js.org/) | MIT |
531
+ | [KaTeX](https://katex.org/) | MIT |
532
+ | [marked-katex-extension](https://github.com/UziTech/marked-katex-extension) | MIT |
533
+ | [Mermaid](https://mermaid.js.org/) | MIT |
431
534
 
432
535
  ---
433
536
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pyfrilet",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "main": "pyfrilet.js",
6
6
  "files": [
package/pyfrilet.js CHANGED
@@ -21,6 +21,9 @@
21
21
  (function () {
22
22
  'use strict';
23
23
 
24
+ /* Capture immediately — document.currentScript becomes null after script execution */
25
+ const _pfScriptTag = document.currentScript;
26
+
24
27
  /* ═══════════════════════════ CDN URLS ═══════════════════════════════ */
25
28
  let isCdn = false; /* set in DOMContentLoaded from data-sources attribute */
26
29
  const CDN = {
@@ -31,11 +34,15 @@ const CDN = {
31
34
  aceMonokai : 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/theme-monokai.min.js',
32
35
  aceLangTools: 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/ext-language_tools.min.js',
33
36
  aceSearchbox: 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/ext-searchbox.min.js',
37
+ marked : 'https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.0/marked.min.js',
38
+ katexCss : 'https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css',
39
+ katex : 'https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js',
40
+ markedKatex : 'https://cdn.jsdelivr.net/npm/marked-katex-extension@5.1.1/lib/index.umd.js',
41
+ mermaid : 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js',
34
42
  };
35
43
 
36
44
  /* ═══════════════════════════ STYLES ═════════════════════════════════ */
37
- const STYLES = `
38
- html, body {
45
+ const STYLES = `html, body {
39
46
  height: 100%; margin: 0; overflow: hidden;
40
47
  background: #111;
41
48
  }
@@ -204,8 +211,162 @@ html, body {
204
211
  flex: 1;
205
212
  min-height: 80px;
206
213
  position: relative;
214
+ display: flex;
215
+ flex-direction: column;
216
+ }
217
+ #pf-ace { flex: 1; position: relative; min-height: 0; }
218
+
219
+ /* ── tab bar ── */
220
+ #pf-tabs {
221
+ display: flex;
222
+ flex-shrink: 0;
223
+ background: #1a1b2e;
224
+ border-bottom: 1px solid #414868;
225
+ overflow-x: auto;
226
+ scrollbar-width: none;
227
+ }
228
+ #pf-tabs:empty { display: none; }
229
+ .pf-tab {
230
+ padding: 5px 14px;
231
+ font-size: 12px;
232
+ background: transparent;
233
+ border: none;
234
+ border-bottom: 2px solid transparent;
235
+ color: #737aa2;
236
+ cursor: pointer;
237
+ white-space: nowrap;
238
+ transition: color .15s, border-color .15s;
239
+ }
240
+ .pf-tab:hover { color: #c0caf5; }
241
+ .pf-tab.pf-tab-active { color: #c0caf5; border-bottom-color: #7aa2f7; }
242
+ .pf-tab.pf-tab-readonly::after { content: ' 🔒'; font-size: 10px; opacity: .6; }
243
+ .pf-tab.pf-tab-markdown::after { content: ' ✎'; font-size: 11px; opacity: .6; }
244
+
245
+ /* ── markdown view ── */
246
+ @import url('https://fonts.googleapis.com/css2?family=Alegreya+Sans:ital,wght@0,400;0,700;1,400&display=swap');
247
+
248
+ #pf-markdown-view {
249
+ flex: 1;
250
+ overflow: auto;
251
+ background: #f4f4f0;
252
+ }
253
+
254
+ #pf-markdown-view .pf-md-inner {
255
+ width: 100%;
256
+ max-width: 680px;
257
+ margin: 0 auto;
258
+ padding: 48px 48px 72px;
259
+ box-sizing: border-box;
260
+ font-family: 'Alegreya Sans', Georgia, serif;
261
+ font-size: 17px;
262
+ line-height: 1.8;
263
+ color: #1c1c2e;
264
+ }
265
+
266
+ #pf-markdown-view h1 {
267
+ font-size: 2.1em;
268
+ font-weight: 700;
269
+ color: #1c1c2e;
270
+ margin: 0 0 .3em;
271
+ padding-bottom: .3em;
272
+ border-bottom: 2px solid #d8d8e8;
273
+ line-height: 1.2;
274
+ }
275
+ #pf-markdown-view h2 {
276
+ font-size: 1.4em;
277
+ font-weight: 700;
278
+ color: #1c1c2e;
279
+ margin: 2em 0 .5em;
280
+ padding-bottom: .2em;
281
+ border-bottom: 1px solid #e0e0ec;
282
+ }
283
+ #pf-markdown-view h3 {
284
+ font-size: 1.1em;
285
+ font-weight: 700;
286
+ color: #2a2a4a;
287
+ margin: 1.6em 0 .4em;
288
+ }
289
+
290
+ #pf-markdown-view p { margin: .75em 0; }
291
+ #pf-markdown-view ul,
292
+ #pf-markdown-view ol { padding-left: 1.6em; margin: .75em 0; }
293
+ #pf-markdown-view li { margin: .3em 0; }
294
+ #pf-markdown-view hr { border: none; border-top: 1px solid #dde; margin: 2em 0; }
295
+ #pf-markdown-view blockquote {
296
+ margin: 1em 0;
297
+ padding: .5em 1em;
298
+ border-left: 3px solid #aab;
299
+ color: #555;
300
+ background: #ededf5;
301
+ border-radius: 0 4px 4px 0;
302
+ }
303
+
304
+ #pf-markdown-view code {
305
+ font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
306
+ font-size: .84em;
307
+ background: #e8e8f2;
308
+ color: #3a3a6a;
309
+ padding: .15em .45em;
310
+ border-radius: 4px;
311
+ }
312
+ #pf-markdown-view pre {
313
+ background: #1a1b2e;
314
+ border-radius: 8px;
315
+ padding: 1em 1.2em;
316
+ overflow: auto;
317
+ margin: 1.2em 0;
318
+ box-shadow: 0 2px 8px rgba(0,0,0,.12);
319
+ }
320
+ #pf-markdown-view pre code {
321
+ background: transparent;
322
+ color: #c0caf5;
323
+ font-size: .86em;
324
+ padding: 0;
325
+ line-height: 1.6;
326
+ border-radius: 0;
327
+ }
328
+
329
+ #pf-markdown-view table {
330
+ border-collapse: collapse;
331
+ width: 100%;
332
+ margin: 1.2em 0;
333
+ font-size: .95em;
334
+ }
335
+ #pf-markdown-view th {
336
+ background: #e4e4f0;
337
+ color: #1c1c2e;
338
+ font-weight: 700;
339
+ text-align: left;
340
+ padding: .55em .85em;
341
+ border: 1px solid #d0d0e8;
342
+ }
343
+ #pf-markdown-view td {
344
+ padding: .5em .85em;
345
+ border: 1px solid #e0e0ee;
346
+ vertical-align: top;
347
+ }
348
+ #pf-markdown-view tr:nth-child(even) td { background: #f0f0f8; }
349
+
350
+ #pf-markdown-view a {
351
+ color: #3a5fc8;
352
+ text-decoration: none;
353
+ border-bottom: 1px solid rgba(58,95,200,.3);
354
+ transition: color .15s, border-color .15s;
355
+ }
356
+ #pf-markdown-view a:hover { color: #1a3fa0; border-bottom-color: #1a3fa0; }
357
+
358
+ #pf-markdown-view .katex-display {
359
+ overflow-x: auto;
360
+ padding: .5em 0;
361
+ margin: 1.2em 0;
362
+ }
363
+ #pf-markdown-view .mermaid {
364
+ text-align: center;
365
+ margin: 1.5em 0;
366
+ background: #ededf5;
367
+ border-radius: 8px;
368
+ padding: 1em;
207
369
  }
208
- #pf-ace { position: absolute; inset: 0; }
209
370
 
210
371
  /* ── error panel (below editor, never overlaps ACE) ── */
211
372
  #pf-err {
@@ -219,12 +380,10 @@ html, body {
219
380
  white-space: pre-wrap;
220
381
  display: none;
221
382
  border-top: 1px solid rgba(247, 118, 142, .35);
222
- }
223
- `;
383
+ }`;
224
384
 
225
385
  /* ═══════════════════════════ MARKUP ═════════════════════════════════ */
226
- const MARKUP = `
227
- <div id="pf-root">
386
+ const MARKUP = `<div id="pf-root">
228
387
  <div id="pf-app" tabindex="-1">
229
388
  <div id="pf-viewport"><div id="pf-sketch"></div></div>
230
389
  <div id="pf-loader">
@@ -246,37 +405,48 @@ const MARKUP = `
246
405
  </div>
247
406
  </div>
248
407
  <div id="pf-editor-wrap">
408
+ <div id="pf-tabs"></div>
409
+ <div id="pf-markdown-view" style="display:none"></div>
249
410
  <div id="pf-ace"></div>
250
411
  </div>
251
412
  <pre id="pf-err"></pre>
252
413
  </div>
253
- </div>
254
- `;
414
+ </div>`;
255
415
 
256
416
  /* ═══════════════════════════ ENTRY POINT ════════════════════════════ */
257
417
  document.addEventListener('DOMContentLoaded', function () {
258
418
 
259
- const pyTag = document.querySelector('script[type="text/python"]')
260
- || document.querySelector('python');
261
- if (!pyTag) {
419
+ /* Collect all python/markdown script blocks in DOM order */
420
+ const allScripts = [
421
+ ...document.querySelectorAll(
422
+ 'script[type="text/python"], script[type="text/markdown"], python'
423
+ )
424
+ ];
425
+
426
+ if (allScripts.length === 0) {
262
427
  console.warn('[pyfrilet] No <script type="text/python"> or <python> tag found.');
263
428
  return;
264
429
  }
265
430
 
431
+ /* Config tag: prefer the <script src="pyfrilet.js"> itself, fallback to first python tag */
432
+ const configTag = _pfScriptTag || allScripts[0];
433
+
266
434
  const sources = (
267
- pyTag.getAttribute('data-sources') ||
268
- pyTag.getAttribute('sources') ||
269
- 'local'
435
+ configTag.getAttribute('data-sources') ||
436
+ configTag.getAttribute('sources') ||
437
+ 'cdn'
270
438
  ).toLowerCase().trim();
271
439
 
272
440
  const vpRaw = (
273
- pyTag.getAttribute('data-vendor') ||
274
- pyTag.getAttribute('vendor') ||
441
+ configTag.getAttribute('data-vendor') ||
442
+ configTag.getAttribute('vendor') ||
275
443
  'vendor/'
276
444
  );
277
- const vp = vpRaw.replace(/\/?$/, '/'); /* ensure trailing slash */
445
+ const vp = vpRaw.replace(/\/?$/, '/');
278
446
 
279
447
  isCdn = sources === 'cdn';
448
+ const hasMarked = allScripts.some(el => el.getAttribute('type') === 'text/markdown');
449
+
280
450
  const URLS = isCdn ? {
281
451
  p5 : CDN.p5,
282
452
  pyodide : CDN.pyodide,
@@ -286,6 +456,11 @@ document.addEventListener('DOMContentLoaded', function () {
286
456
  aceMonokai : CDN.aceMonokai,
287
457
  aceLangTools: CDN.aceLangTools,
288
458
  aceSearchbox: CDN.aceSearchbox,
459
+ marked : hasMarked ? CDN.marked : null,
460
+ katexCss : hasMarked ? CDN.katexCss : null,
461
+ katex : hasMarked ? CDN.katex : null,
462
+ markedKatex : hasMarked ? CDN.markedKatex : null,
463
+ mermaid : hasMarked ? CDN.mermaid : null,
289
464
  } : {
290
465
  p5 : vp + 'p5.min.js',
291
466
  pyodide : vp + 'pyodide/pyodide.js',
@@ -295,20 +470,87 @@ document.addEventListener('DOMContentLoaded', function () {
295
470
  aceMonokai : vp + 'theme-monokai.min.js',
296
471
  aceLangTools: vp + 'ext-language_tools.min.js',
297
472
  aceSearchbox: vp + 'ext-searchbox.min.js',
473
+ marked : hasMarked ? vp + 'marked.min.js' : null,
474
+ katexCss : hasMarked ? vp + 'katex.min.css' : null,
475
+ katex : hasMarked ? vp + 'katex.min.js' : null,
476
+ markedKatex : hasMarked ? vp + 'marked-katex-extension.js' : null,
477
+ mermaid : hasMarked ? vp + 'mermaid.min.js' : null,
298
478
  };
299
479
 
300
- /* Dedent / strip leading blank line from embedded code */
301
- const starterCode = pyTag.textContent.replace(/^\n/, '');
480
+ const SK = 'pyfrilet:' + location.pathname;
481
+
482
+ /* ── Parse HTML as ground truth ─────────────────────────────────────
483
+ htmlTabs = what the current file says, used for:
484
+ - starterCode (reset target)
485
+ - fallback when no snapshot exists
486
+ ──────────────────────────────────────────────────────────────────── */
487
+ const htmlTabs = allScripts.map((el, i) => {
488
+ const type = el.getAttribute('type') === 'text/markdown' ? 'markdown' : 'python';
489
+ const hidden = el.hasAttribute('data-hidden');
490
+ const readonly = el.hasAttribute('data-readonly');
491
+ let label = el.getAttribute('data-tab');
492
+ if (label === null && !hidden) label = allScripts.length === 1 ? 'Code' : `Bloc ${i + 1}`;
493
+ const rawCode = el.textContent.replace(/^\n/, '');
494
+ return { id: 'tab-' + i, label, hidden, readonly, type, starterCode: rawCode, code: rawCode };
495
+ });
496
+
497
+ /* ── Try to restore full snapshot from localStorage ─────────────────
498
+ Snapshot format (v1): { v:1, tabs:[{ label, hidden, readonly, type, content }, ...] }
499
+
500
+ If a valid snapshot exists → use it as the working tabs (same structure
501
+ as last visit, including number of tabs and all content).
502
+ The starterCode of each tab is taken from the matching htmlTab (by label+type)
503
+ so that Reset always targets the current file, not the stored version.
302
504
 
303
- const SK = 'pyfrilet:' + location.pathname;
304
- const saved = (() => { try { return localStorage.getItem(SK); } catch (e) { return null; } })();
305
- const initialCode = (saved && saved.trim()) ? saved : starterCode;
505
+ If no snapshot use htmlTabs, with retro-compat fallback on old per-tab keys.
506
+ ──────────────────────────────────────────────────────────────────── */
507
+ const tryLS = (key) => { try { return localStorage.getItem(key); } catch (e) { return null; } };
508
+
509
+ let tabs;
510
+ const raw = tryLS(SK);
511
+ let snap = null;
512
+ if (raw) {
513
+ try { snap = JSON.parse(raw); } catch (e) { snap = null; }
514
+ }
306
515
 
307
- main(initialCode, starterCode, SK, URLS);
516
+ if (snap && snap.v === 1 && Array.isArray(snap.tabs) && snap.tabs.length > 0) {
517
+ /* Restore structure and content from snapshot */
518
+ tabs = snap.tabs.map((st, i) => {
519
+ /* Find current htmlTab with same label+type to get up-to-date starterCode */
520
+ const html = htmlTabs.find(h => h.label === st.label && h.type === st.type) || null;
521
+ return {
522
+ id : 'tab-' + i,
523
+ label : st.label,
524
+ hidden : st.hidden,
525
+ readonly : st.readonly,
526
+ type : st.type,
527
+ starterCode : html ? html.starterCode : st.content, /* reset target = current file */
528
+ code : st.content, /* working content = last visit */
529
+ };
530
+ });
531
+ } else {
532
+ /* No snapshot: use htmlTabs with retro-compat for old per-tab keys */
533
+ tabs = htmlTabs.map((tab, i) => {
534
+ if (!tab.hidden && !tab.readonly && tab.type === 'python') {
535
+ const safe = tab.label ? tab.label.replace(/[^a-zA-Z0-9]/g, '_') : String(i);
536
+ let saved = tryLS(SK + ':' + safe);
537
+ /* Retro-compat: single unnamed tab used bare SK as key */
538
+ if (!saved && tab.label === 'Code' && htmlTabs.length === 1) saved = tryLS(SK);
539
+ if (saved && saved.trim()) return { ...tab, code: saved };
540
+ }
541
+ return tab;
542
+ });
543
+ }
544
+
545
+ main(tabs, htmlTabs, SK, URLS);
308
546
  });
309
547
 
310
548
  /* ═══════════════════════════ MAIN ═══════════════════════════════════ */
311
- function main(initialCode, starterCode, SK, URLS) {
549
+ function main(tabs, htmlTabs, SK, URLS) {
550
+
551
+ /* tabs = working state (from snapshot or HTML), may be reassigned on reset
552
+ htmlTabs = ground truth from current HTML file, never mutated */
553
+ tabs = tabs.slice(); /* local mutable copy */
312
554
 
313
555
  /* ── inject styles + markup ── */
314
556
  const styleEl = document.createElement('style');
@@ -333,6 +575,8 @@ function main(initialCode, starterCode, SK, URLS) {
333
575
  const btnHelp = document.getElementById('pf-btn-help');
334
576
  const gripEl = document.getElementById('pf-grip');
335
577
  const hintEl = document.getElementById('pf-handle-hint');
578
+ const tabsEl = document.getElementById('pf-tabs');
579
+ const markdownEl = document.getElementById('pf-markdown-view');
336
580
 
337
581
  /* ─────────────────── DRAWER ─────────────────── */
338
582
  let drawerOpen = false;
@@ -475,6 +719,7 @@ function main(initialCode, starterCode, SK, URLS) {
475
719
  (_rawMouseY - r.top) * sy,
476
720
  ];
477
721
  };
722
+
478
723
  function showError(txt) { errEl.textContent = txt; errEl.style.display = 'block'; openDrawer(); }
479
724
  function clearError() { errEl.textContent = ''; errEl.style.display = 'none'; }
480
725
 
@@ -605,20 +850,134 @@ function main(initialCode, starterCode, SK, URLS) {
605
850
  if (touchEndedProxy) { touchEndedProxy.destroy(); touchEndedProxy = null; }
606
851
  }
607
852
 
608
- /* ─────────────────── ACE EDITOR ─────────────── */
609
- let aceInst = null;
853
+ /* ─────────────────── ACE EDITOR + TABS ─────────────── */
854
+ let aceInst = null;
855
+ let activeTab = null;
856
+
857
+ /* Map tab.id → ACE EditSession */
858
+ const aceSessions = {};
859
+
860
+ /* All pending save debounce timers — cancelled before any reset */
861
+ const saveTimers = new Set();
862
+
863
+ /* ── Tab bar ──────────────────────────────────────────── */
864
+ function buildTabBar() {
865
+ /* Clear existing buttons */
866
+ tabsEl.innerHTML = '';
867
+ activeTab = null;
868
+
869
+ const visibleTabs = tabs.filter(t => !t.hidden);
870
+ tabsEl.style.display = visibleTabs.length <= 1 ? 'none' : '';
871
+
872
+ visibleTabs.forEach(tab => {
873
+ const btn = document.createElement('button');
874
+ btn.className = 'pf-tab';
875
+ btn.dataset.tabId = tab.id;
876
+ btn.textContent = tab.label;
877
+ if (tab.readonly) btn.classList.add('pf-tab-readonly');
878
+ if (tab.type === 'markdown') btn.classList.add('pf-tab-markdown');
879
+ btn.addEventListener('click', () => switchTab(tab));
880
+ tabsEl.appendChild(btn);
881
+ });
610
882
 
883
+ if (visibleTabs.length > 0) switchTab(visibleTabs[0], true);
884
+ }
885
+
886
+ function switchTab(tab, init) {
887
+ if (!init && activeTab === tab) return;
888
+ activeTab = tab;
889
+
890
+ tabsEl.querySelectorAll('.pf-tab').forEach(btn => {
891
+ btn.classList.toggle('pf-tab-active', btn.dataset.tabId === tab.id);
892
+ });
893
+
894
+ if (tab.type === 'markdown') {
895
+ document.getElementById('pf-ace').style.display = 'none';
896
+ markdownEl.style.display = 'block';
897
+ if (window.marked) {
898
+ let html = marked.parse(tab.starterCode);
899
+ if (window.mermaid) {
900
+ html = html.replace(
901
+ /<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g,
902
+ (_, code) => `<div class="mermaid">${
903
+ code.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
904
+ }</div>`
905
+ );
906
+ }
907
+ markdownEl.innerHTML = `<div class="pf-md-inner">${html}</div>`;
908
+ } else {
909
+ markdownEl.innerHTML = `<div class="pf-md-inner"><pre>${tab.starterCode}</pre></div>`;
910
+ }
911
+ if (window.mermaid) mermaid.run({ nodes: markdownEl.querySelectorAll('.mermaid') });
912
+ } else {
913
+ document.getElementById('pf-ace').style.display = 'block';
914
+ markdownEl.style.display = 'none';
915
+ if (aceInst && aceSessions[tab.id]) {
916
+ aceInst.setSession(aceSessions[tab.id]);
917
+ aceInst.setReadOnly(tab.readonly);
918
+ aceInst.focus();
919
+ }
920
+ }
921
+ }
922
+
923
+ /* ── Line number offsets ──────────────────────────────── */
924
+ /* Each visible tab's firstLineNumber is set so ACE line numbers match
925
+ Python traceback line numbers (all python tabs concatenated with join('\n')). */
926
+ function updateLineOffsets() {
927
+ let offset = 1;
928
+ tabs.filter(t => t.type === 'python').forEach(tab => {
929
+ if (!tab.hidden && !tab.readonly && aceSessions[tab.id]) {
930
+ aceSessions[tab.id].setOption('firstLineNumber', offset);
931
+ offset += aceSessions[tab.id].getLength();
932
+ } else {
933
+ offset += tab.code.split('\n').length;
934
+ }
935
+ });
936
+ }
937
+
938
+ /* ── ACE sessions ─────────────────────────────────────── */
939
+ function buildSessions() {
940
+ /* Always create fresh sessions — reusing an active session causes ACE
941
+ to skip the display refresh even after setValue(). */
942
+ Object.keys(aceSessions).forEach(id => delete aceSessions[id]);
943
+
944
+ tabs.filter(t => !t.hidden && t.type === 'python').forEach(tab => {
945
+ const session = ace.createEditSession(tab.code, 'ace/mode/python');
946
+ session.setUseWorker(false);
947
+ session.setTabSize(4);
948
+ aceSessions[tab.id] = session;
949
+
950
+ if (!tab.readonly) {
951
+ let saveTimer = null;
952
+ session.on('change', () => {
953
+ if (saveTimer !== null) { clearTimeout(saveTimer); saveTimers.delete(saveTimer); }
954
+ saveTimer = setTimeout(() => { saveTimers.delete(saveTimer); saveTimer = null; saveSnapshot(); }, 350);
955
+ saveTimers.add(saveTimer);
956
+ updateLineOffsets();
957
+ refreshDirty();
958
+ });
959
+ }
960
+ });
961
+
962
+ /* Point ACE instance at first python session and force a full redraw */
963
+ const firstPython = tabs.find(t => !t.hidden && t.type === 'python');
964
+ if (aceInst && firstPython && aceSessions[firstPython.id]) {
965
+ aceInst.setSession(aceSessions[firstPython.id]);
966
+ aceInst.setReadOnly(firstPython.readonly);
967
+ aceInst.renderer.updateFull(true);
968
+ }
969
+
970
+ updateLineOffsets();
971
+ }
972
+
973
+ /* ── Init ACE (once) ──────────────────────────────────── */
611
974
  function initAce() {
612
- /* In local mode ACE cannot auto-detect where its dynamic modules live
613
- (searchbox, keybindings…), so we set basePath explicitly. */
614
975
  if (URLS.ace.startsWith('vendor') || !URLS.ace.startsWith('http')) {
615
976
  ace.config.set('basePath', URLS.ace.replace(/\/[^/]+$/, '/'));
616
977
  }
978
+
617
979
  aceInst = ace.edit('pf-ace');
618
- aceInst.session.setMode('ace/mode/python');
619
980
  aceInst.setTheme('ace/theme/monokai');
620
- aceInst.setValue(initialCode, -1);
621
- btnReset.classList.toggle('pf-dirty', initialCode !== starterCode);
622
981
  aceInst.setOptions({
623
982
  fontSize : '15px',
624
983
  showPrintMargin: false,
@@ -630,13 +989,11 @@ function main(initialCode, starterCode, SK, URLS) {
630
989
  enableSnippets : true,
631
990
  });
632
991
 
992
+ /* Keyboard shortcuts (registered once) */
633
993
  aceInst.commands.addCommand({
634
994
  name: 'pfRun',
635
995
  bindKey: { win: 'Shift-Enter', mac: 'Shift-Enter' },
636
- exec: () => {
637
- if (aceInst.completer?.popup?.isOpen) return;
638
- runCode();
639
- },
996
+ exec: () => { if (aceInst.completer?.popup?.isOpen) return; runCode(); },
640
997
  });
641
998
  aceInst.commands.addCommand({
642
999
  name: 'pfClose',
@@ -652,24 +1009,69 @@ function main(initialCode, starterCode, SK, URLS) {
652
1009
  name: 'pfReset',
653
1010
  bindKey: { win: 'Ctrl-R', mac: 'Command-R' },
654
1011
  exec: () => {
655
- if (confirm('Réinitialiser le code ? Les modifications seront perdues.')) {
656
- aceInst.setValue(starterCode, -1);
657
- runCode();
658
- }
1012
+ if (confirm('Réinitialiser ? Les modifications seront perdues.')) resetAllTabs();
659
1013
  },
660
1014
  });
661
1015
 
662
- let saveTimer = null;
663
- aceInst.session.on('change', () => {
664
- clearTimeout(saveTimer);
665
- saveTimer = setTimeout(saveCode, 350);
666
- btnReset.classList.toggle('pf-dirty', aceInst.getValue() !== starterCode);
667
- });
1016
+ buildSessions();
1017
+ buildTabBar();
1018
+ refreshDirty();
668
1019
  }
669
1020
 
670
- function saveCode() {
671
- try { localStorage.setItem(SK, aceInst ? aceInst.getValue() : initialCode); } catch (e) {}
1021
+ /* ── Persistence ──────────────────────────────────────── */
1022
+ function saveSnapshot() {
1023
+ const snapshot = {
1024
+ v: 1,
1025
+ tabs: tabs.map(tab => ({
1026
+ label : tab.label,
1027
+ hidden : tab.hidden,
1028
+ readonly: tab.readonly,
1029
+ type : tab.type,
1030
+ content : (!tab.hidden && !tab.readonly && tab.type === 'python' && aceSessions[tab.id])
1031
+ ? aceSessions[tab.id].getValue()
1032
+ : tab.code,
1033
+ })),
1034
+ };
1035
+ try { localStorage.setItem(SK, JSON.stringify(snapshot)); } catch (e) {}
1036
+ }
1037
+
1038
+ function saveCode() { saveSnapshot(); }
1039
+
1040
+ /* ── Dirty indicator ──────────────────────────────────── */
1041
+ function refreshDirty() {
1042
+ const dirty = tabs.some(tab =>
1043
+ !tab.hidden && !tab.readonly && tab.type === 'python' &&
1044
+ aceSessions[tab.id] &&
1045
+ aceSessions[tab.id].getValue() !== tab.starterCode
1046
+ );
1047
+ btnReset.classList.toggle('pf-dirty', dirty);
672
1048
  }
1049
+
1050
+ /* ── Reset: restore file structure + content, no reload ─ */
1051
+ function resetAllTabs() {
1052
+ /* Cancel all pending saves first */
1053
+ saveTimers.forEach(t => clearTimeout(t));
1054
+ saveTimers.clear();
1055
+
1056
+ /* Erase snapshot and any legacy per-tab keys */
1057
+ try { localStorage.removeItem(SK); } catch (e) {}
1058
+ tabs.forEach(tab => {
1059
+ if (tab.label) {
1060
+ try { localStorage.removeItem(SK + ':' + tab.label.replace(/[^a-zA-Z0-9]/g, '_')); } catch (e) {}
1061
+ }
1062
+ });
1063
+ try { localStorage.removeItem(SK + ':Code'); } catch (e) {}
1064
+
1065
+ /* Rebuild tabs from htmlTabs (file structure, starterCode as working code) */
1066
+ tabs = htmlTabs.map((ht, i) => ({ ...ht, id: 'tab-' + i, code: ht.starterCode }));
1067
+
1068
+ /* Rebuild sessions and tab bar in-memory */
1069
+ buildSessions();
1070
+ buildTabBar();
1071
+ refreshDirty();
1072
+ runCode();
1073
+ }
1074
+
673
1075
  window.addEventListener('beforeunload', saveCode);
674
1076
 
675
1077
  /* ─────────────────── PYODIDE ────────────────── */
@@ -931,7 +1333,14 @@ m.__getattr__ = _p5_getattr
931
1333
 
932
1334
  loaderEl.style.display = 'none';
933
1335
 
934
- const code = aceInst ? aceInst.getValue() : initialCode;
1336
+ /* Concatenate all python tabs in DOM order (hidden + visible) */
1337
+ const code = tabs
1338
+ .filter(t => t.type === 'python')
1339
+ .map(t => {
1340
+ if (!t.hidden && !t.readonly && aceSessions[t.id]) return aceSessions[t.id].getValue();
1341
+ return t.code; /* hidden or readonly: use original/starter code */
1342
+ })
1343
+ .join('\n');
935
1344
 
936
1345
  /* Auto-load any Pyodide-bundled packages the sketch imports. */
937
1346
  try {
@@ -1059,7 +1468,7 @@ m.__getattr__ = _p5_getattr
1059
1468
  }
1060
1469
 
1061
1470
  /* ─────────────────── DOWNLOAD ───────────────── */
1062
- const PYFRILET_CDN = 'https://cdn.jsdelivr.net/npm/pyfrilet@latest/pyfrilet.min.js';
1471
+ const PYFRILET_CDN = 'https://cdn.jsdelivr.net/npm/pyfrilet@0.5.1/pyfrilet.min.js';
1063
1472
 
1064
1473
  const STANDALONE_TEMPLATE = `<!doctype html>
1065
1474
  <html lang="fr">
@@ -1071,16 +1480,36 @@ m.__getattr__ = _p5_getattr
1071
1480
  </head>
1072
1481
  <body>
1073
1482
 
1074
- <script type="text/python" data-sources="cdn">
1075
- FILLME-PYTHON
1076
- <\/script>
1483
+ FILLME-SCRIPTS
1077
1484
 
1078
1485
  </body>
1079
1486
  </html>`;
1080
1487
 
1081
1488
  function download() {
1082
- const code = aceInst ? aceInst.getValue() : initialCode;
1083
- const html = STANDALONE_TEMPLATE.replace('FILLME-PYTHON', code);
1489
+ /* Reconstruct all script tags preserving structure, attributes, and current editor content */
1490
+ const scripts = tabs.map((tab, i) => {
1491
+ /* Get current content */
1492
+ let content;
1493
+ if (tab.type === 'python' && !tab.hidden && !tab.readonly && aceSessions[tab.id]) {
1494
+ content = aceSessions[tab.id].getValue();
1495
+ } else {
1496
+ content = tab.code;
1497
+ }
1498
+
1499
+ /* Rebuild attributes */
1500
+ const attrs = [];
1501
+ const scriptType = tab.type === 'markdown' ? 'text/markdown' : 'text/python';
1502
+ if (tab.label !== null) attrs.push(`data-tab="${tab.label.replace(/"/g, '&quot;')}"`);
1503
+ if (tab.hidden) attrs.push('data-hidden');
1504
+ if (tab.readonly) attrs.push('data-readonly');
1505
+
1506
+ const attrStr = attrs.length ? ' ' + attrs.join(' ') : '';
1507
+ /* Escape </script> inside content */
1508
+ const safe = content.replace(/<\/script>/gi, '<\\/script>');
1509
+ return `<script type="${scriptType}"${attrStr}>\n${safe}\n<\/script>`;
1510
+ }).join('\n\n');
1511
+
1512
+ const html = STANDALONE_TEMPLATE.replace('FILLME-SCRIPTS', scripts);
1084
1513
  const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
1085
1514
  const url = URL.createObjectURL(blob);
1086
1515
  const a = Object.assign(document.createElement('a'), { href: url, download: 'sketch.html' });
@@ -1090,20 +1519,6 @@ FILLME-PYTHON
1090
1519
  URL.revokeObjectURL(url);
1091
1520
  }
1092
1521
 
1093
- /* ─────────────────── BUTTON HANDLERS ────────── */
1094
- btnRun.addEventListener('click', () => runCode());
1095
-
1096
- /* Code button: open at full screen height, or close if already open */
1097
- btnCode.addEventListener('click', () => {
1098
- if (drawerOpen) {
1099
- closeDrawer();
1100
- } else {
1101
- drawerH = window.innerHeight - 32;
1102
- _applyDrawerH();
1103
- openDrawer();
1104
- }
1105
- });
1106
-
1107
1522
  /* ── WebM recording ─────────────────────────────────────────────── */
1108
1523
  let mediaRecorder = null;
1109
1524
  let recChunks = [];
@@ -1142,14 +1557,28 @@ FILLME-PYTHON
1142
1557
  mediaRecorder ? stopRecording() : startRecording();
1143
1558
  });
1144
1559
 
1560
+ /* ─────────────────── BUTTON HANDLERS ────────── */
1561
+ btnRun.addEventListener('click', () => runCode());
1562
+
1563
+ /* Code button: open at full screen height, or close if already open */
1564
+ btnCode.addEventListener('click', () => {
1565
+ if (drawerOpen) {
1566
+ closeDrawer();
1567
+ } else {
1568
+ drawerH = window.innerHeight - 32;
1569
+ _applyDrawerH();
1570
+ openDrawer();
1571
+ }
1572
+ });
1573
+
1145
1574
  btnDl.addEventListener('click', download);
1575
+
1146
1576
  const HELP_URL = 'https://codeberg.org/nopid/pyfrilet';
1147
1577
  btnHelp.addEventListener('click', () => window.open(HELP_URL, '_blank'));
1148
1578
 
1149
1579
  btnReset.addEventListener('click', () => {
1150
- if (aceInst && confirm('Réinitialiser le code ? Les modifications seront perdues.')) {
1151
- aceInst.setValue(starterCode, -1);
1152
- runCode();
1580
+ if (confirm('Réinitialiser ? Les modifications seront perdues.')) {
1581
+ resetAllTabs();
1153
1582
  }
1154
1583
  });
1155
1584
 
@@ -1210,9 +1639,8 @@ FILLME-PYTHON
1210
1639
  /* Ctrl/Cmd+R: reset code (prevent browser reload) */
1211
1640
  if ((ev.key === 'r' || ev.key === 'R') && (ev.ctrlKey || ev.metaKey) && !ev.altKey) {
1212
1641
  ev.preventDefault();
1213
- if (aceInst && confirm('Réinitialiser le code ? Les modifications seront perdues.')) {
1214
- aceInst.setValue(starterCode, -1);
1215
- runCode();
1642
+ if (confirm('Réinitialiser ? Les modifications seront perdues.')) {
1643
+ resetAllTabs();
1216
1644
  }
1217
1645
  return;
1218
1646
  }
@@ -1235,6 +1663,22 @@ FILLME-PYTHON
1235
1663
 
1236
1664
  try {
1237
1665
  await loadScript(URLS.p5);
1666
+ if (URLS.marked) {
1667
+ /* Load KaTeX CSS first (no JS dependency) */
1668
+ const katexLink = document.createElement('link');
1669
+ katexLink.rel = 'stylesheet';
1670
+ katexLink.href = URLS.katexCss;
1671
+ document.head.appendChild(katexLink);
1672
+ /* Then JS libs in order */
1673
+ await loadScript(URLS.marked);
1674
+ await loadScript(URLS.katex);
1675
+ await loadScript(URLS.markedKatex);
1676
+ await loadScript(URLS.mermaid);
1677
+ /* Configure marked: KaTeX extension */
1678
+ marked.use(markedKatex({ throwOnError: false }));
1679
+ /* Initialize mermaid (startOnLoad:false — we call run() manually) */
1680
+ mermaid.initialize({ startOnLoad: false, theme: 'neutral' });
1681
+ }
1238
1682
  await loadScript(URLS.ace);
1239
1683
  await loadScript(URLS.acePython);
1240
1684
  await loadScript(URLS.aceMonokai);
@@ -1251,6 +1695,8 @@ FILLME-PYTHON
1251
1695
  await runCode();
1252
1696
  loaderEl.style.display = 'none';
1253
1697
  })();
1254
- }
1255
1698
 
1256
- })();
1699
+
1700
+ } /* end main() */
1701
+
1702
+ })(); /* end IIFE */
package/pyfrilet.min.js CHANGED
@@ -1 +1 @@
1
- !function(){"use strict";let e=!1;const n="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.4/p5.min.js",t="https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js",o="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/ace.min.js",a="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/mode-python.min.js",s="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/theme-monokai.min.js",i="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/ext-language_tools.min.js",r="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/ext-searchbox.min.js",l="\nhtml, body {\n height: 100%; margin: 0; overflow: hidden;\n background: #111;\n}\n#pf-root {\n position: fixed; inset: 0;\n display: flex; flex-direction: column;\n font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;\n}\n\n/* ── app area ── */\n#pf-app:focus { outline: none; }\n#pf-app {\n flex: 1; min-height: 0;\n position: relative;\n background: #111;\n display: flex; align-items: center; justify-content: center;\n overflow: hidden;\n}\n#pf-viewport {\n transform-origin: 50% 50%;\n will-change: transform;\n}\n#pf-viewport canvas {\n display: block;\n outline: none;\n}\n#pf-loader {\n position: absolute; inset: 0;\n display: flex; flex-direction: column;\n align-items: center; justify-content: center;\n gap: 14px;\n background: #111;\n color: #565f89;\n font-size: 13px;\n z-index: 50;\n pointer-events: none;\n}\n#pf-loader-bar {\n width: 160px; height: 2px;\n background: #2a2c3e;\n border-radius: 2px;\n overflow: hidden;\n}\n#pf-loader-bar::after {\n content: '';\n display: block;\n height: 100%;\n width: 40%;\n background: #7aa2f7;\n border-radius: 2px;\n animation: pf-slide 1.2s ease-in-out infinite;\n}\n@keyframes pf-slide {\n 0% { transform: translateX(-100%); }\n 100% { transform: translateX(350%); }\n}\n\n/* ── drawer (slide-up editor panel) ── */\n#pf-drawer {\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n background: #1a1b26;\n height: 32px; /* collapsed = handle only */\n transition: height 0.26s cubic-bezier(.4, 0, .2, 1);\n overflow: hidden;\n /* shadow cast upward onto the app */\n box-shadow: 0 -4px 20px rgba(0,0,0,.55);\n}\n#pf-drawer.pf-open {\n height: var(--pf-drawer-h, 56vh);\n}\n\n/* ── handle bar ── */\n#pf-handle {\n height: 32px;\n min-height: 32px;\n display: flex;\n align-items: center;\n padding: 0 8px 0 6px;\n background: #24283b;\n border-top: 1px solid #3d4166;\n cursor: ns-resize;\n user-select: none;\n gap: 6px;\n flex-shrink: 0;\n}\n/* grip zone: clickable to toggle, draggable to resize */\n#pf-grip {\n display: flex;\n flex-direction: column;\n gap: 3px;\n padding: 5px 6px;\n flex-shrink: 0;\n opacity: .5;\n border-radius: 4px;\n transition: opacity .15s, background .15s;\n cursor: pointer;\n}\n#pf-grip:hover { opacity: .85; background: rgba(255,255,255,.06); }\n#pf-grip span {\n display: block;\n width: 16px; height: 2px;\n background: #a9b1d6;\n border-radius: 1px;\n}\n#pf-handle-hint {\n flex: 1;\n color: #565f89;\n font-size: 10px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n#pf-handle-btns {\n display: flex;\n gap: 4px;\n flex-shrink: 0;\n}\n.pf-btn {\n height: 26px;\n min-width: 26px;\n padding: 0 5px;\n border: 0; border-radius: 5px;\n cursor: pointer;\n display: flex; align-items: center; justify-content: center;\n font-size: 13px; line-height: 1;\n white-space: nowrap;\n transition: background .15s, transform .1s, opacity .15s;\n outline: none;\n box-sizing: border-box;\n}\n.pf-btn:active { transform: scale(.88); }\n.pf-btn:focus-visible { outline: 2px solid #7aa2f7; outline-offset: 1px; }\n\n#pf-btn-run { background: #1a6b3a; color: #9ece6a; font-size: 11px; }\n#pf-btn-run:hover { background: #1f8447; color: #b9f27a; }\n#pf-btn-run.pf-running { opacity: .5; cursor: not-allowed; }\n\n#pf-btn-code { background: #2a2c3e; color: #7aa2f7; font-size: 14px; }\n#pf-btn-code:hover { background: #3d4166; color: #c0caf5; }\n#pf-btn-code.pf-active { background: #3d4166; color: #e0af68; }\n\n#pf-btn-dl { background: #2a2c3e; color: #9d7cd8; font-size: 14px; }\n#pf-btn-dl:hover { background: #3d4166; color: #bb9af7; }\n\n#pf-btn-rec { background: #2a2c3e; color: #f7768e; font-size: 13px; }\n#pf-btn-rec:hover { background: #3d4166; color: #ff9e9e; }\n#pf-btn-rec.pf-recording { background: #6b1a1a; color: #f7768e; animation: pf-blink .8s step-end infinite; }\n@keyframes pf-blink { 50% { opacity: .4; } }\n\n#pf-btn-reset { background: #2a2c3e; color: #e0af68; font-size: 16px; }\n#pf-btn-reset:hover { background: #3d4166; color: #ffc777; }\n#pf-btn-reset.pf-dirty::after {\n content: '●';\n position: absolute;\n top: 2px; right: 3px;\n font-size: 7px;\n color: #e0af68;\n line-height: 1;\n}\n#pf-btn-reset { position: relative; }\n\n/* ── editor area inside drawer ── */\n#pf-editor-wrap {\n flex: 1;\n min-height: 80px;\n position: relative;\n}\n#pf-ace { position: absolute; inset: 0; }\n\n/* ── error panel (below editor, never overlaps ACE) ── */\n#pf-err {\n flex-shrink: 0;\n max-height: 120px;\n overflow: auto;\n margin: 0; padding: 8px 13px;\n font-size: 11.5px; line-height: 1.45;\n background: rgba(13, 3, 3, .95);\n color: #f7768e;\n white-space: pre-wrap;\n display: none;\n border-top: 1px solid rgba(247, 118, 142, .35);\n}\n",d='\n<div id="pf-root">\n <div id="pf-app" tabindex="-1">\n <div id="pf-viewport"><div id="pf-sketch"></div></div>\n <div id="pf-loader">\n <span id="pf-loader-msg">Chargement…</span>\n <div id="pf-loader-bar"></div>\n </div>\n </div>\n <div id="pf-drawer">\n <div id="pf-handle">\n <div id="pf-grip" title="Clic → ouvrir/fermer"><span></span><span></span><span></span></div>\n <span id="pf-handle-hint">Clic ☰ → ouvrir/fermer &nbsp;·&nbsp; Shift+Entrée → relancer</span>\n <div id="pf-handle-btns">\n <button class="pf-btn" id="pf-btn-run" title="Relancer (Shift+Entrée)">&#9654;</button>\n <button class="pf-btn" id="pf-btn-code" title="Éditeur plein écran">&#9999;&#xFE0F;</button>\n <button class="pf-btn" id="pf-btn-dl" title="Télécharger HTML autonome">&#128190;</button>\n <button class="pf-btn" id="pf-btn-rec" title="Enregistrer WebM">⏺</button>\n <button class="pf-btn" id="pf-btn-help" title="Aide">?</button>\n <button class="pf-btn" id="pf-btn-reset" title="Réinitialiser le code (Ctrl+R)">&#8635;</button>\n </div>\n </div>\n <div id="pf-editor-wrap">\n <div id="pf-ace"></div>\n </div>\n <pre id="pf-err"></pre>\n </div>\n</div>\n';document.addEventListener("DOMContentLoaded",function(){const c=document.querySelector('script[type="text/python"]')||document.querySelector("python");if(!c)return void console.warn('[pyfrilet] No <script type="text/python"> or <python> tag found.');const p=(c.getAttribute("data-sources")||c.getAttribute("sources")||"local").toLowerCase().trim(),u=(c.getAttribute("data-vendor")||c.getAttribute("vendor")||"vendor/").replace(/\/?$/,"/");e="cdn"===p;const f=e?{p5:n,pyodide:t,pyodideIndex:null,ace:o,acePython:a,aceMonokai:s,aceLangTools:i,aceSearchbox:r}:{p5:u+"p5.min.js",pyodide:u+"pyodide/pyodide.js",pyodideIndex:u+"pyodide/",ace:u+"ace.min.js",acePython:u+"mode-python.min.js",aceMonokai:u+"theme-monokai.min.js",aceLangTools:u+"ext-language_tools.min.js",aceSearchbox:u+"ext-searchbox.min.js"},m=c.textContent.replace(/^\n/,""),_="pyfrilet:"+location.pathname,h=(()=>{try{return localStorage.getItem(_)}catch(e){return null}})();!function(n,t,o,a){const s=document.createElement("style");s.textContent=l,document.head.appendChild(s),document.body.innerHTML=d;const i=document.getElementById("pf-app"),r=document.getElementById("pf-drawer"),c=document.getElementById("pf-handle"),p=document.getElementById("pf-sketch"),u=document.getElementById("pf-viewport"),f=document.getElementById("pf-loader"),m=document.getElementById("pf-loader-msg"),_=document.getElementById("pf-err"),h=document.getElementById("pf-btn-run"),y=document.getElementById("pf-btn-code"),g=document.getElementById("pf-btn-dl"),b=document.getElementById("pf-btn-rec"),v=document.getElementById("pf-btn-reset"),x=document.getElementById("pf-btn-help"),w=document.getElementById("pf-grip"),k=document.getElementById("pf-handle-hint");let E=!1,C=Math.round(.56*window.innerHeight);function L(){document.documentElement.style.setProperty("--pf-drawer-h",C+"px")}function S(){E=!0,r.classList.add("pf-open"),y.classList.add("pf-active"),setTimeout(()=>{F(),V&&V.focus()},280)}function z(){E=!1,r.classList.remove("pf-open"),y.classList.remove("pf-active"),setTimeout(()=>{F();const e=K._p?.canvas;e&&e.removeAttribute("tabindex"),i.focus()},280)}function R(){E?z():S()}L();let j=null;const I=5,P=120,M=document.createElement("div");function B(e){if(e.target.closest(".pf-btn"))return;if(e.target.closest("#pf-grip"))return;const n=e.touches?e.touches[0].clientY:e.clientY;j={y:n,h:E?C:0,moved:!1},M.style.display="block",document.body.style.userSelect="none",e.cancelable&&e.preventDefault(),e.stopPropagation()}function T(e){if(!j)return;const n=e.touches?e.touches[0].clientY:e.clientY,t=j.y-n;if(Math.abs(t)>I&&(j.moved=!0),!j.moved)return;const o=Math.max(0,Math.min(window.innerHeight-50,j.h+t));o<P?(r.style.transition="none",r.style.height="32px"):(C=o,L(),E||S(),r.style.transition="none",r.style.height=C+"px"),F()}function O(e){if(!j)return;const n=j.moved,t=(e.changedTouches?e.changedTouches[0].clientY:e.clientY)??j.y,o=j.y-t,a=j.h+o;j=null,M.style.display="none",document.body.style.userSelect="",r.style.transition="",r.style.height="",n&&(a<P?z():(C=Math.max(P,Math.min(window.innerHeight-50,a)),L(),E||S()),F())}Object.assign(M.style,{position:"fixed",inset:"0",zIndex:"9999",cursor:"ns-resize",display:"none"}),document.body.appendChild(M),w.addEventListener("click",e=>{e.stopPropagation(),R()}),c.addEventListener("mousedown",B,!0),document.addEventListener("mousemove",T),document.addEventListener("mouseup",O),c.addEventListener("touchstart",B,{passive:!1}),document.addEventListener("touchmove",T,{passive:!0}),document.addEventListener("touchend",O);let A=0,W=0;function D(e){_.textContent=e,_.style.display="block",S()}function U(){_.textContent="",_.style.display="none"}function N(){if(!K._p||"fit"!==K._mode)return;const e=K._w,n=K._h;if(!e||!n)return;const t=i.clientWidth,o=i.clientHeight,a=Math.min(t/e,o/n);u.style.transform=`scale(${a})`}function F(){if("fullscreen"===K._mode?K.size("max"):N(),Y&&"function"==typeof Y.windowResized)try{Y.windowResized()}catch(e){D(String(e))}V&&V.resize()}window.addEventListener("mousemove",e=>{A=e.clientX,W=e.clientY},{passive:!0}),window.addEventListener("touchmove",e=>{e.touches.length>0&&(A=e.touches[0].clientX,W=e.touches[0].clientY)},{passive:!0}),window._pfMouse=()=>{const e=K._p?K._p.canvas:null;if(!e)return[0,0];const n=e.getBoundingClientRect(),t=K._w/n.width,o=K._h/n.height;return[(A-n.left)*t,(W-n.top)*o]},window.addEventListener("resize",F);let Y=null;const K=new Proxy({_p:null,_mode:"fit",_w:0,_h:0,_setP(e){this._p=e},size(e,n,t){if(!this._p)return;const o=t??void 0;"max"===e||null==e?(this._mode="fullscreen",this._w=i.clientWidth,this._h=i.clientHeight,void 0===o&&this._p.canvas?this._p.resizeCanvas(this._w,this._h):this._p.createCanvas(this._w,this._h,o),u.style.transform="scale(1)"):(this._mode="fit",this._w=Math.max(1,0|e),this._h=Math.max(1,0|n),void 0===o&&this._p.canvas?this._p.resizeCanvas(this._w,this._h):this._p.createCanvas(this._w,this._h,o),N())},noSmooth(){this._p?.noSmooth(),this._p?.canvas&&(this._p.canvas.style.imageRendering="pixelated")},smooth(){this._p?.smooth(),this._p?.canvas&&(this._p.canvas.style.imageRendering="auto")},sketchTitle(e){k.textContent=String(e)},getItem(e){try{return localStorage.getItem(e)}catch(e){return null}},storeItem(e,n){try{localStorage.setItem(e,String(n))}catch(e){}},removeItem(e){try{localStorage.removeItem(e)}catch(e){}},clearStorage(){try{localStorage.clear()}catch(e){}}},{get(e,n){if(n in e)return"function"==typeof e[n]?e[n].bind(e):e[n];if(e._p&&n in e._p){const t=e._p[n];return"function"==typeof t?t.bind(e._p):t}},set:(e,n,t)=>n.startsWith("_")?(e[n]=t,!0):(e._p&&(e._p[n]=t),!0)});function H(){if(ve(),Y){try{Y.remove()}catch(e){}Y=null}p.innerHTML="",K._p=null,K._mode="fit",K._w=0,K._h=0,u.style.transform="scale(1)",k.textContent="Shift+Entrée → relancer  ·  Échap → ouvrir/fermer",te&&(te.destroy(),te=null),ee&&(ee.destroy(),ee=null),ne&&(ne.destroy(),ne=null),oe&&(oe.destroy(),oe=null),ae&&(ae.destroy(),ae=null),se&&(se.destroy(),se=null),ie&&(ie.destroy(),ie=null),re&&(re.destroy(),re=null),le&&(le.destroy(),le=null),de&&(de.destroy(),de=null),ce&&(ce.destroy(),ce=null),pe&&(pe.destroy(),pe=null),ue&&(ue.destroy(),ue=null),fe&&(fe.destroy(),fe=null)}window.p5py=K;let V=null;function X(){!a.ace.startsWith("vendor")&&a.ace.startsWith("http")||ace.config.set("basePath",a.ace.replace(/\/[^/]+$/,"/")),V=ace.edit("pf-ace"),V.session.setMode("ace/mode/python"),V.setTheme("ace/theme/monokai"),V.setValue(n,-1),v.classList.toggle("pf-dirty",n!==t),V.setOptions({fontSize:"15px",showPrintMargin:!1,wrap:!1,useWorker:!1,tabSize:4,enableBasicAutocompletion:!0,enableLiveAutocompletion:!0,enableSnippets:!0}),V.commands.addCommand({name:"pfRun",bindKey:{win:"Shift-Enter",mac:"Shift-Enter"},exec:()=>{V.completer?.popup?.isOpen||me()}}),V.commands.addCommand({name:"pfClose",bindKey:{win:"Escape",mac:"Escape"},exec:z}),V.commands.addCommand({name:"pfSave",bindKey:{win:"Ctrl-S",mac:"Command-S"},exec:J}),V.commands.addCommand({name:"pfReset",bindKey:{win:"Ctrl-R",mac:"Command-R"},exec:()=>{confirm("Réinitialiser le code ? Les modifications seront perdues.")&&(V.setValue(t,-1),me())}});let e=null;V.session.on("change",()=>{clearTimeout(e),e=setTimeout(J,350),v.classList.toggle("pf-dirty",V.getValue()!==t)})}function J(){try{localStorage.setItem(o,V?V.getValue():n)}catch(e){}}window.addEventListener("beforeunload",J);let $=null,G=null;async function q(){return G||(G=(async()=>{const e={};if(a.pyodideIndex&&(e.indexURL=a.pyodideIndex),$=await loadPyodide(e),$.runPython("\nimport sys, types, js\nfrom js import p5py, _pfMouse\nfrom pyodide.ffi import JsProxy\n\n# ── Python builtins that must NOT be shadowed ──────────────────────\n_BLACKLIST = frozenset({\n 'abs','all','any','bin','bool','bytes','callable','chr','compile',\n 'delattr','dict','dir','divmod','enumerate','eval','exec',\n 'filter','float','format','frozenset','getattr','globals','hasattr',\n 'hash','help','hex','id','input','int','isinstance','issubclass',\n 'iter','len','list','locals','map','max','min','next','object',\n 'oct','open','ord','pow','print','property','range','repr',\n 'reversed','round','set','setattr','slice','sorted','staticmethod',\n 'str','sum','super','tuple','type','vars','zip',\n # p5 lifecycle hooks — user defines these, we don't import them\n 'setup','draw','preload',\n})\n\n# ── Introspect a hidden dummy p5 instance ─────────────────────────\n_dummy_node = js.document.createElement('div')\n_dummy = js.p5.new(lambda _: None, _dummy_node)\n\n_p5_functions = set() # names of callable JS members\n_p5_attributes = set() # names of scalar/readable members\n\nfor _n in dir(_dummy):\n if _n.startswith('_') or _n in _BLACKLIST:\n continue\n _v = getattr(_dummy, _n)\n if isinstance(_v, JsProxy):\n if callable(_v):\n _p5_functions.add(_n)\n # non-callable JsProxy (canvas, pixels…) → skip\n else:\n _p5_attributes.add(_n)\n\n# Read real initial values now, while dummy is still alive\n_attr_init = {}\nfor _n in _p5_attributes:\n try:\n _attr_init[_n] = getattr(_dummy, _n)\n except Exception:\n _attr_init[_n] = 0\n\n_dummy.remove()\ndel _dummy, _dummy_node\n\n# ── Build module ───────────────────────────────────────────────────\nm = types.ModuleType(\"p5\")\n\n# Generic function wrapper: delegates to live p5Bridge instance\nclass _FW:\n __slots__ = ('_n',)\n def __init__(self, n): self._n = n\n def __call__(self, *a): return getattr(p5py, self._n)(*a)\n def __repr__(self): return f'<p5 function {self._n}>'\n\nfor _n in _p5_functions:\n setattr(m, _n, _FW(_n))\n\n# ── Special overrides (our bridge has custom behaviour) ────────────\n# smooth/noSmooth exist on a real p5 instance so introspection finds\n# them — but our Proxy overrides them to also toggle CSS image-rendering.\n# size and sketchTitle are pyfrilet-only: NOT on a real p5 instance,\n# so introspection misses them — add them explicitly.\nfor _n in ('sketchTitle',):\n setattr(m, _n, _FW(_n))\n _p5_functions.add(_n) # keep __all__ consistent\n\n# size() calls _pf_refresh after resizing so width/height are immediately\n# correct in setup() — consistent with p5.js JS behaviour.\nclass _SizeWrapper:\n def __call__(self, *a):\n p5py.size(*a)\n _pf_refresh(_ns_ref[0])\n return _GetCanvasWrapper()()\n def __repr__(self): return '<p5 function size>'\nsetattr(m, 'size', _SizeWrapper())\nsetattr(m, 'createCanvas', m.size) # alias — createCanvas(...) == size(...)\n_p5_functions.add('size')\n_p5_functions.add('createCanvas')\n_ns_ref = [{}] # filled in by runCode before each exec\n\n# getCanvas() — returns the p5.Element wrapping the canvas,\n# so the user can call .drop(create_proxy(fn)), .mouseOver(), etc. directly like in JS.\nclass _GetCanvasWrapper:\n def __call__(self):\n p = p5py._p\n if p is None:\n raise RuntimeError('getCanvas() doit être appelé dans setup() ou après')\n p.canvas.id = '__pf_canvas__'\n return p.select('#__pf_canvas__')\n def __repr__(self): return '<p5 function getCanvas>'\nsetattr(m, 'getCanvas', _GetCanvasWrapper())\n_p5_functions.add('getCanvas')\n\n# mouseX / mouseY: override with our accurate coordinate calculator\n# (p5's own values are wrong when a CSS-transformed parent is used)\n_MOUSE_OVERRIDE = frozenset({'mouseX', 'mouseY'})\n\n# Initial values from the dummy instance — constants like WEBGL, DEGREES,\n# LEFT_ARROW… are correct from the very first setup() call.\nfor _n in _p5_attributes:\n if _n in _MOUSE_OVERRIDE:\n setattr(m, _n, 0.0)\n else:\n setattr(m, _n, _attr_init.get(_n, 0))\n\n# Build __all__ for import * — done later, after snake_case aliases are added\n\n# ── _pf_refresh: called before every event callback ───────────────\nimport re as _re\n\n# Pre-compute snake_case alias for each attribute — None if identical\n_attr_snake = {\n _k: (_re.sub(r'([A-Z])', lambda x: '_' + x.group(1).lower(), _k) or None)\n for _k in _p5_attributes\n}\n_attr_snake = {_k: (_s if _s != _k else None) for _k, _s in _attr_snake.items()}\n\n# Add snake_case names to _p5_attributes so __all__ and _pf_refresh cover them\nfor _k, _sk in list(_attr_snake.items()):\n if _sk:\n _p5_attributes.add(_sk)\n setattr(m, _sk, getattr(m, _k, 0)) # initial value mirrors camelCase\n _attr_snake[_sk] = None # snake name has no further alias\n\ndef _pf_refresh(ns):\n # accurate mouse coords (bypasses p5's stale CSS-transform offset)\n mx, my = _pfMouse()\n\n # update all known scalar attributes from live instance\n for _k in _p5_attributes:\n _sk = _attr_snake.get(_k)\n if _k in _MOUSE_OVERRIDE:\n _v = mx if _k in ('mouseX', 'mouse_x') else my\n elif _sk is None and _k not in _attr_snake:\n # pure snake_case entry — skip, updated via its camelCase counterpart\n continue\n else:\n try:\n _v = getattr(p5py, _k)\n except Exception:\n continue\n setattr(m, _k, _v)\n if _k in ns:\n ns[_k] = _v\n if _sk:\n setattr(m, _sk, _v)\n if _sk in ns:\n ns[_sk] = _v\n\nsys.modules[\"p5\"] = m\n\ndef _snake_to_camel(name):\n parts = name.split('_')\n return parts[0] + ''.join(p.capitalize() for p in parts[1:])\n\n# Pre-populate snake_case aliases so \"from p5 import no_fill\" works\nfor _camel in list(vars(m).keys()):\n _snake = _re.sub(r'([A-Z])', lambda x: '_' + x.group(1).lower(), _camel)\n if _snake != _camel and not hasattr(m, _snake):\n setattr(m, _snake, getattr(m, _camel))\n if _camel in _p5_functions:\n _p5_functions.add(_snake)\n\n# Rebuild __all__ now that snake_case aliases are included\nm.__all__ = sorted(_p5_functions | _p5_attributes)\n\ndef _p5_getattr(name):\n camel = _snake_to_camel(name)\n if camel != name:\n val = getattr(m, camel, None)\n if val is not None:\n return val\n raise AttributeError(f\"module 'p5' has no attribute '{name}'\")\n\nm.__getattr__ = _p5_getattr\n"),V){Z($.runPython("list(m.__all__)").toJs())}})(),G)}function Z(e){const n=e.map(e=>({caption:e,value:e,meta:"p5",score:1e3})),t={getCompletions(e,t,o,a,s){s(null,a.length>0?n:[])}},o=ace.require("ace/ext/language_tools");o&&Array.isArray(o.completers)&&(o.completers=o.completers.filter(e=>!0!==e._pyfrilet)),t._pyfrilet=!0,V.completers=[...V.completers||[],t]}let Q=!1,ee=null,ne=null,te=null,oe=null,ae=null,se=null,ie=null,re=null,le=null,de=null,ce=null,pe=null,ue=null,fe=null;async function me(){if(Q)return;Q=!0,h.classList.add("pf-running"),U(),H(),$||(m.textContent="Initialisation de Pyodide…",f.style.display="flex");try{await q()}catch(e){return f.style.display="none",D("Erreur Pyodide : "+e),Q=!1,void h.classList.remove("pf-running")}f.style.display="none";const t=V?V.getValue():n;try{m.textContent="Chargement des dépendances…",f.style.display="flex",await $.loadPackagesFromImports(t,{messageCallback:()=>{},checkIntegrity:e})}catch(e){console.warn("[pyfrilet] loadPackagesFromImports:",e)}f.style.display="none",$.globals.set("_USER_CODE",t);try{$.runPython("_ns = {}; exec(_USER_CODE, _ns, _ns)"),$.runPython("_ns_ref[0] = _ns")}catch(e){return D(String(e)),Q=!1,void h.classList.remove("pf-running")}let o,a,s,i,r,l,d,c,u,_,y,g,b,v;try{const e=(e,n)=>$.runPython(`_ns.get('${e}') or _ns.get('${n}')`);r=e("preload","preload"),o=e("setup","setup"),a=e("draw","draw"),s=e("mousePressed","mouse_pressed"),i=e("keyPressed","key_pressed"),l=e("mouseDragged","mouse_dragged"),d=e("mouseReleased","mouse_released"),c=e("mouseMoved","mouse_moved"),u=e("mouseWheel","mouse_wheel"),_=e("doubleClicked","double_clicked"),y=e("keyReleased","key_released"),g=e("touchStarted","touch_started"),b=e("touchMoved","touch_moved"),v=e("touchEnded","touch_ended")}catch(e){return D(String(e)),Q=!1,void h.classList.remove("pf-running")}if(!a)return D("Le script doit définir au moins une fonction draw()."),Q=!1,void h.classList.remove("pf-running");const{create_proxy:x}=$.pyimport("pyodide.ffi"),w=$.runPython("_ns.get('windowResized')"),k=$.globals.get("_pf_refresh"),E=$.globals.get("_ns"),C=e=>e?x(()=>{try{k(E),e()}catch(e){D(String(e))}}):null;te=r?x(()=>{try{r()}catch(e){D(String(e))}}):null,ee=o?x(()=>{try{o()}catch(e){D(String(e))}}):null;const L=200;ne=x(()=>{try{k(E);const e=performance.now();a(),performance.now()-e>L&&(H(),D(`draw() a mis plus de ${L} ms — sketch arrêté pour protéger le navigateur.`))}catch(e){D(String(e)),H()}}),oe=C(s),ae=C(d),se=C(l),ie=C(c),re=C(u),le=C(_),de=C(i),ce=C(y),pe=C(g),ue=C(b),fe=C(v);const S=w?x(()=>{try{w()}catch(e){D(String(e))}}):null;let z=!1;Y=new p5(e=>{K._setP(e),te&&(e.preload=()=>{te()}),e.setup=()=>{ee&&ee(),e.canvas||K.size(200,200),"function"==typeof e._updateMouseCoords&&e._updateMouseCoords({clientX:0,clientY:0}),e.windowResized(),z=!0},e.draw=()=>{z&&ne()},e.mousePressed=()=>{z&&oe&&oe()},e.mouseReleased=()=>{z&&ae&&ae()},e.mouseDragged=()=>{z&&se&&se()},e.mouseMoved=()=>{z&&ie&&ie()},e.mouseWheel=e=>{z&&re&&re()},e.doubleClicked=()=>{z&&le&&le()},e.keyPressed=()=>{z&&de&&de()},e.keyReleased=()=>{z&&ce&&ce()},pe&&(e.touchStarted=()=>{z&&pe()}),ue&&(e.touchMoved=()=>{z&&ue()}),fe&&(e.touchEnded=()=>{z&&fe()}),e.windowResized=()=>{"fullscreen"===K._mode?K.size("max"):N(),S&&S()}},p),Q=!1,h.classList.remove("pf-running")}const _e='<!doctype html>\n<html lang="fr">\n<head>\n <meta charset="utf-8">\n <meta name="viewport" content="width=device-width, initial-scale=1">\n <title>export</title>\n <script src="https://cdn.jsdelivr.net/npm/pyfrilet@0.4.3/pyfrilet.min.js"><\/script>\n</head>\n<body>\n\n<script type="text/python" data-sources="cdn">\nFILLME-PYTHON\n<\/script>\n\n</body>\n</html>';function he(){const e=V?V.getValue():n,t=_e.replace("FILLME-PYTHON",e),o=new Blob([t],{type:"text/html;charset=utf-8"}),a=URL.createObjectURL(o),s=Object.assign(document.createElement("a"),{href:a,download:"sketch.html"});document.body.appendChild(s),s.click(),document.body.removeChild(s),URL.revokeObjectURL(a)}h.addEventListener("click",()=>me()),y.addEventListener("click",()=>{E?z():(C=window.innerHeight-32,L(),S())});let ye=null,ge=[];function be(){const e=K._p?.canvas;if(!e)return;const n=["video/webm;codecs=vp9","video/webm;codecs=vp8","video/webm"].find(e=>MediaRecorder.isTypeSupported(e))||"video/webm",t=e.captureStream();ye=new MediaRecorder(t,{mimeType:n}),ge=[],ye.ondataavailable=e=>{e.data.size&&ge.push(e.data)},ye.onstop=()=>{const e=new Blob(ge,{type:n}),t=URL.createObjectURL(e),o=n.includes("webm")?"webm":"mp4";Object.assign(document.createElement("a"),{href:t,download:`sketch.${o}`}).click(),URL.revokeObjectURL(t),b.textContent="⏺",b.title="Enregistrer WebM",b.classList.remove("pf-recording"),ye=null},ye.start(),b.textContent="⏹",b.title="Arrêter l'enregistrement",b.classList.add("pf-recording")}function ve(){ye&&"inactive"!==ye.state&&ye.stop()}b.addEventListener("click",()=>{ye?ve():be()}),g.addEventListener("click",he);const xe="https://codeberg.org/nopid/pyfrilet";function we(e){return new Promise((n,t)=>{const o=document.createElement("script");o.src=e,o.onload=n,o.onerror=()=>t(new Error("Impossible de charger : "+e)),document.head.appendChild(o)})}x.addEventListener("click",()=>window.open(xe,"_blank")),v.addEventListener("click",()=>{V&&confirm("Réinitialiser le code ? Les modifications seront perdues.")&&(V.setValue(t,-1),me())}),window.addEventListener("keydown",e=>{const n=E&&V&&V.isFocused&&V.isFocused();if(n||!["ArrowLeft","ArrowRight","ArrowUp","ArrowDown"].includes(e.key)){if("Enter"===e.key&&e.shiftKey)return e.preventDefault(),void me();if("Escape"===e.key){const t=document.querySelector(".ace_search");if(t&&"none"!==t.style.display)return e.preventDefault(),e.stopPropagation(),V.searchBox?V.searchBox.hide():t.style.display="none",void V.focus();if(n){const n=V.completer?.popup?.isOpen;if(n)return;return e.preventDefault(),e.stopPropagation(),void z()}return e.preventDefault(),e.stopPropagation(),void(E?z():S())}if(!n)return"s"!==e.key&&"S"!==e.key||!e.ctrlKey&&!e.metaKey?"r"!==e.key&&"R"!==e.key||!e.ctrlKey&&!e.metaKey||e.altKey?void 0:(e.preventDefault(),void(V&&confirm("Réinitialiser le code ? Les modifications seront perdues.")&&(V.setValue(t,-1),me()))):(e.preventDefault(),void J())}else e.preventDefault()},!0),(async()=>{m.textContent="Chargement des dépendances…",f.style.display="flex";try{await we(a.p5),await we(a.ace),await we(a.acePython),await we(a.aceMonokai),await we(a.aceLangTools),await we(a.aceSearchbox),await we(a.pyodide)}catch(e){return m.textContent="⚠ "+e.message,void(document.getElementById("pf-loader-bar").style.display="none")}X(),await me(),f.style.display="none"})()}(h&&h.trim()?h:m,m,_,f)})}();
1
+ !function(){"use strict";const e=document.currentScript;let n=!1;const t="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.4/p5.min.js",a="https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js",o="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/ace.min.js",r="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/mode-python.min.js",i="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/theme-monokai.min.js",s="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/ext-language_tools.min.js",d="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/ext-searchbox.min.js",l="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.0/marked.min.js",c="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css",p="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js",m="https://cdn.jsdelivr.net/npm/marked-katex-extension@5.1.1/lib/index.umd.js",f="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js",u="html, body {\n height: 100%; margin: 0; overflow: hidden;\n background: #111;\n}\n#pf-root {\n position: fixed; inset: 0;\n display: flex; flex-direction: column;\n font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;\n}\n\n/* ── app area ── */\n#pf-app:focus { outline: none; }\n#pf-app {\n flex: 1; min-height: 0;\n position: relative;\n background: #111;\n display: flex; align-items: center; justify-content: center;\n overflow: hidden;\n}\n#pf-viewport {\n transform-origin: 50% 50%;\n will-change: transform;\n}\n#pf-viewport canvas {\n display: block;\n outline: none;\n}\n#pf-loader {\n position: absolute; inset: 0;\n display: flex; flex-direction: column;\n align-items: center; justify-content: center;\n gap: 14px;\n background: #111;\n color: #565f89;\n font-size: 13px;\n z-index: 50;\n pointer-events: none;\n}\n#pf-loader-bar {\n width: 160px; height: 2px;\n background: #2a2c3e;\n border-radius: 2px;\n overflow: hidden;\n}\n#pf-loader-bar::after {\n content: '';\n display: block;\n height: 100%;\n width: 40%;\n background: #7aa2f7;\n border-radius: 2px;\n animation: pf-slide 1.2s ease-in-out infinite;\n}\n@keyframes pf-slide {\n 0% { transform: translateX(-100%); }\n 100% { transform: translateX(350%); }\n}\n\n/* ── drawer (slide-up editor panel) ── */\n#pf-drawer {\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n background: #1a1b26;\n height: 32px; /* collapsed = handle only */\n transition: height 0.26s cubic-bezier(.4, 0, .2, 1);\n overflow: hidden;\n /* shadow cast upward onto the app */\n box-shadow: 0 -4px 20px rgba(0,0,0,.55);\n}\n#pf-drawer.pf-open {\n height: var(--pf-drawer-h, 56vh);\n}\n\n/* ── handle bar ── */\n#pf-handle {\n height: 32px;\n min-height: 32px;\n display: flex;\n align-items: center;\n padding: 0 8px 0 6px;\n background: #24283b;\n border-top: 1px solid #3d4166;\n cursor: ns-resize;\n user-select: none;\n gap: 6px;\n flex-shrink: 0;\n}\n/* grip zone: clickable to toggle, draggable to resize */\n#pf-grip {\n display: flex;\n flex-direction: column;\n gap: 3px;\n padding: 5px 6px;\n flex-shrink: 0;\n opacity: .5;\n border-radius: 4px;\n transition: opacity .15s, background .15s;\n cursor: pointer;\n}\n#pf-grip:hover { opacity: .85; background: rgba(255,255,255,.06); }\n#pf-grip span {\n display: block;\n width: 16px; height: 2px;\n background: #a9b1d6;\n border-radius: 1px;\n}\n#pf-handle-hint {\n flex: 1;\n color: #565f89;\n font-size: 10px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n#pf-handle-btns {\n display: flex;\n gap: 4px;\n flex-shrink: 0;\n}\n.pf-btn {\n height: 26px;\n min-width: 26px;\n padding: 0 5px;\n border: 0; border-radius: 5px;\n cursor: pointer;\n display: flex; align-items: center; justify-content: center;\n font-size: 13px; line-height: 1;\n white-space: nowrap;\n transition: background .15s, transform .1s, opacity .15s;\n outline: none;\n box-sizing: border-box;\n}\n.pf-btn:active { transform: scale(.88); }\n.pf-btn:focus-visible { outline: 2px solid #7aa2f7; outline-offset: 1px; }\n\n#pf-btn-run { background: #1a6b3a; color: #9ece6a; font-size: 11px; }\n#pf-btn-run:hover { background: #1f8447; color: #b9f27a; }\n#pf-btn-run.pf-running { opacity: .5; cursor: not-allowed; }\n\n#pf-btn-code { background: #2a2c3e; color: #7aa2f7; font-size: 14px; }\n#pf-btn-code:hover { background: #3d4166; color: #c0caf5; }\n#pf-btn-code.pf-active { background: #3d4166; color: #e0af68; }\n\n#pf-btn-dl { background: #2a2c3e; color: #9d7cd8; font-size: 14px; }\n#pf-btn-dl:hover { background: #3d4166; color: #bb9af7; }\n\n#pf-btn-rec { background: #2a2c3e; color: #f7768e; font-size: 13px; }\n#pf-btn-rec:hover { background: #3d4166; color: #ff9e9e; }\n#pf-btn-rec.pf-recording { background: #6b1a1a; color: #f7768e; animation: pf-blink .8s step-end infinite; }\n@keyframes pf-blink { 50% { opacity: .4; } }\n\n#pf-btn-reset { background: #2a2c3e; color: #e0af68; font-size: 16px; }\n#pf-btn-reset:hover { background: #3d4166; color: #ffc777; }\n#pf-btn-reset.pf-dirty::after {\n content: '●';\n position: absolute;\n top: 2px; right: 3px;\n font-size: 7px;\n color: #e0af68;\n line-height: 1;\n}\n#pf-btn-reset { position: relative; }\n\n/* ── editor area inside drawer ── */\n#pf-editor-wrap {\n flex: 1;\n min-height: 80px;\n position: relative;\n display: flex;\n flex-direction: column;\n}\n#pf-ace { flex: 1; position: relative; min-height: 0; }\n\n/* ── tab bar ── */\n#pf-tabs {\n display: flex;\n flex-shrink: 0;\n background: #1a1b2e;\n border-bottom: 1px solid #414868;\n overflow-x: auto;\n scrollbar-width: none;\n}\n#pf-tabs:empty { display: none; }\n.pf-tab {\n padding: 5px 14px;\n font-size: 12px;\n background: transparent;\n border: none;\n border-bottom: 2px solid transparent;\n color: #737aa2;\n cursor: pointer;\n white-space: nowrap;\n transition: color .15s, border-color .15s;\n}\n.pf-tab:hover { color: #c0caf5; }\n.pf-tab.pf-tab-active { color: #c0caf5; border-bottom-color: #7aa2f7; }\n.pf-tab.pf-tab-readonly::after { content: ' 🔒'; font-size: 10px; opacity: .6; }\n.pf-tab.pf-tab-markdown::after { content: ' ✎'; font-size: 11px; opacity: .6; }\n\n/* ── markdown view ── */\n@import url('https://fonts.googleapis.com/css2?family=Alegreya+Sans:ital,wght@0,400;0,700;1,400&display=swap');\n\n#pf-markdown-view {\n flex: 1;\n overflow: auto;\n background: #f4f4f0;\n}\n\n#pf-markdown-view .pf-md-inner {\n width: 100%;\n max-width: 680px;\n margin: 0 auto;\n padding: 48px 48px 72px;\n box-sizing: border-box;\n font-family: 'Alegreya Sans', Georgia, serif;\n font-size: 17px;\n line-height: 1.8;\n color: #1c1c2e;\n}\n\n#pf-markdown-view h1 {\n font-size: 2.1em;\n font-weight: 700;\n color: #1c1c2e;\n margin: 0 0 .3em;\n padding-bottom: .3em;\n border-bottom: 2px solid #d8d8e8;\n line-height: 1.2;\n}\n#pf-markdown-view h2 {\n font-size: 1.4em;\n font-weight: 700;\n color: #1c1c2e;\n margin: 2em 0 .5em;\n padding-bottom: .2em;\n border-bottom: 1px solid #e0e0ec;\n}\n#pf-markdown-view h3 {\n font-size: 1.1em;\n font-weight: 700;\n color: #2a2a4a;\n margin: 1.6em 0 .4em;\n}\n\n#pf-markdown-view p { margin: .75em 0; }\n#pf-markdown-view ul,\n#pf-markdown-view ol { padding-left: 1.6em; margin: .75em 0; }\n#pf-markdown-view li { margin: .3em 0; }\n#pf-markdown-view hr { border: none; border-top: 1px solid #dde; margin: 2em 0; }\n#pf-markdown-view blockquote {\n margin: 1em 0;\n padding: .5em 1em;\n border-left: 3px solid #aab;\n color: #555;\n background: #ededf5;\n border-radius: 0 4px 4px 0;\n}\n\n#pf-markdown-view code {\n font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;\n font-size: .84em;\n background: #e8e8f2;\n color: #3a3a6a;\n padding: .15em .45em;\n border-radius: 4px;\n}\n#pf-markdown-view pre {\n background: #1a1b2e;\n border-radius: 8px;\n padding: 1em 1.2em;\n overflow: auto;\n margin: 1.2em 0;\n box-shadow: 0 2px 8px rgba(0,0,0,.12);\n}\n#pf-markdown-view pre code {\n background: transparent;\n color: #c0caf5;\n font-size: .86em;\n padding: 0;\n line-height: 1.6;\n border-radius: 0;\n}\n\n#pf-markdown-view table {\n border-collapse: collapse;\n width: 100%;\n margin: 1.2em 0;\n font-size: .95em;\n}\n#pf-markdown-view th {\n background: #e4e4f0;\n color: #1c1c2e;\n font-weight: 700;\n text-align: left;\n padding: .55em .85em;\n border: 1px solid #d0d0e8;\n}\n#pf-markdown-view td {\n padding: .5em .85em;\n border: 1px solid #e0e0ee;\n vertical-align: top;\n}\n#pf-markdown-view tr:nth-child(even) td { background: #f0f0f8; }\n\n#pf-markdown-view a {\n color: #3a5fc8;\n text-decoration: none;\n border-bottom: 1px solid rgba(58,95,200,.3);\n transition: color .15s, border-color .15s;\n}\n#pf-markdown-view a:hover { color: #1a3fa0; border-bottom-color: #1a3fa0; }\n\n#pf-markdown-view .katex-display {\n overflow-x: auto;\n padding: .5em 0;\n margin: 1.2em 0;\n}\n#pf-markdown-view .mermaid {\n text-align: center;\n margin: 1.5em 0;\n background: #ededf5;\n border-radius: 8px;\n padding: 1em;\n}\n\n/* ── error panel (below editor, never overlaps ACE) ── */\n#pf-err {\n flex-shrink: 0;\n max-height: 120px;\n overflow: auto;\n margin: 0; padding: 8px 13px;\n font-size: 11.5px; line-height: 1.45;\n background: rgba(13, 3, 3, .95);\n color: #f7768e;\n white-space: pre-wrap;\n display: none;\n border-top: 1px solid rgba(247, 118, 142, .35);\n}",h='<div id="pf-root">\n <div id="pf-app" tabindex="-1">\n <div id="pf-viewport"><div id="pf-sketch"></div></div>\n <div id="pf-loader">\n <span id="pf-loader-msg">Chargement…</span>\n <div id="pf-loader-bar"></div>\n </div>\n </div>\n <div id="pf-drawer">\n <div id="pf-handle">\n <div id="pf-grip" title="Clic → ouvrir/fermer"><span></span><span></span><span></span></div>\n <span id="pf-handle-hint">Clic ☰ → ouvrir/fermer &nbsp;·&nbsp; Shift+Entrée → relancer</span>\n <div id="pf-handle-btns">\n <button class="pf-btn" id="pf-btn-run" title="Relancer (Shift+Entrée)">&#9654;</button>\n <button class="pf-btn" id="pf-btn-code" title="Éditeur plein écran">&#9999;&#xFE0F;</button>\n <button class="pf-btn" id="pf-btn-dl" title="Télécharger HTML autonome">&#128190;</button>\n <button class="pf-btn" id="pf-btn-rec" title="Enregistrer WebM">⏺</button>\n <button class="pf-btn" id="pf-btn-help" title="Aide">?</button>\n <button class="pf-btn" id="pf-btn-reset" title="Réinitialiser le code (Ctrl+R)">&#8635;</button>\n </div>\n </div>\n <div id="pf-editor-wrap">\n <div id="pf-tabs"></div>\n <div id="pf-markdown-view" style="display:none"></div>\n <div id="pf-ace"></div>\n </div>\n <pre id="pf-err"></pre>\n </div>\n</div>';document.addEventListener("DOMContentLoaded",function(){const _=[...document.querySelectorAll('script[type="text/python"], script[type="text/markdown"], python')];if(0===_.length)return void console.warn('[pyfrilet] No <script type="text/python"> or <python> tag found.');const y=e||_[0],b=(y.getAttribute("data-sources")||y.getAttribute("sources")||"cdn").toLowerCase().trim(),g=(y.getAttribute("data-vendor")||y.getAttribute("vendor")||"vendor/").replace(/\/?$/,"/");n="cdn"===b;const v=_.some(e=>"text/markdown"===e.getAttribute("type")),w=n?{p5:t,pyodide:a,pyodideIndex:null,ace:o,acePython:r,aceMonokai:i,aceLangTools:s,aceSearchbox:d,marked:v?l:null,katexCss:v?c:null,katex:v?p:null,markedKatex:v?m:null,mermaid:v?f:null}:{p5:g+"p5.min.js",pyodide:g+"pyodide/pyodide.js",pyodideIndex:g+"pyodide/",ace:g+"ace.min.js",acePython:g+"mode-python.min.js",aceMonokai:g+"theme-monokai.min.js",aceLangTools:g+"ext-language_tools.min.js",aceSearchbox:g+"ext-searchbox.min.js",marked:v?g+"marked.min.js":null,katexCss:v?g+"katex.min.css":null,katex:v?g+"katex.min.js":null,markedKatex:v?g+"marked-katex-extension.js":null,mermaid:v?g+"mermaid.min.js":null},x="pyfrilet:"+location.pathname,k=_.map((e,n)=>{const t="text/markdown"===e.getAttribute("type")?"markdown":"python",a=e.hasAttribute("data-hidden"),o=e.hasAttribute("data-readonly");let r=e.getAttribute("data-tab");null!==r||a||(r=1===_.length?"Code":`Bloc ${n+1}`);const i=e.textContent.replace(/^\n/,"");return{id:"tab-"+n,label:r,hidden:a,readonly:o,type:t,starterCode:i,code:i}}),E=e=>{try{return localStorage.getItem(e)}catch(e){return null}};let C;const S=E(x);let L=null;if(S)try{L=JSON.parse(S)}catch(e){L=null}C=L&&1===L.v&&Array.isArray(L.tabs)&&L.tabs.length>0?L.tabs.map((e,n)=>{const t=k.find(n=>n.label===e.label&&n.type===e.type)||null;return{id:"tab-"+n,label:e.label,hidden:e.hidden,readonly:e.readonly,type:e.type,starterCode:t?t.starterCode:e.content,code:e.content}}):k.map((e,n)=>{if(!e.hidden&&!e.readonly&&"python"===e.type){const t=e.label?e.label.replace(/[^a-zA-Z0-9]/g,"_"):String(n);let a=E(x+":"+t);if(a||"Code"!==e.label||1!==k.length||(a=E(x)),a&&a.trim())return{...e,code:a}}return e}),function(e,t,a,o){e=e.slice();const r=document.createElement("style");r.textContent=u,document.head.appendChild(r),document.body.innerHTML=h;const i=document.getElementById("pf-app"),s=document.getElementById("pf-drawer"),d=document.getElementById("pf-handle"),l=document.getElementById("pf-sketch"),c=document.getElementById("pf-viewport"),p=document.getElementById("pf-loader"),m=document.getElementById("pf-loader-msg"),f=document.getElementById("pf-err"),_=document.getElementById("pf-btn-run"),y=document.getElementById("pf-btn-code"),b=document.getElementById("pf-btn-dl"),g=document.getElementById("pf-btn-rec"),v=document.getElementById("pf-btn-reset"),w=document.getElementById("pf-btn-help"),x=document.getElementById("pf-grip"),k=document.getElementById("pf-handle-hint"),E=document.getElementById("pf-tabs"),C=document.getElementById("pf-markdown-view");let S=!1,L=Math.round(.56*window.innerHeight);function j(){document.documentElement.style.setProperty("--pf-drawer-h",L+"px")}function z(){S=!0,s.classList.add("pf-open"),y.classList.add("pf-active"),setTimeout(()=>{$(),J&&J.focus()},280)}function I(){S=!1,s.classList.remove("pf-open"),y.classList.remove("pf-active"),setTimeout(()=>{$();const e=Y._p?.canvas;e&&e.removeAttribute("tabindex"),i.focus()},280)}function R(){S?I():z()}j();let M=null;const P=5,A=120,B=document.createElement("div");function T(e){if(e.target.closest(".pf-btn"))return;if(e.target.closest("#pf-grip"))return;const n=e.touches?e.touches[0].clientY:e.clientY;M={y:n,h:S?L:0,moved:!1},B.style.display="block",document.body.style.userSelect="none",e.cancelable&&e.preventDefault(),e.stopPropagation()}function O(e){if(!M)return;const n=e.touches?e.touches[0].clientY:e.clientY,t=M.y-n;if(Math.abs(t)>P&&(M.moved=!0),!M.moved)return;const a=Math.max(0,Math.min(window.innerHeight-50,M.h+t));a<A?(s.style.transition="none",s.style.height="32px"):(L=a,j(),S||z(),s.style.transition="none",s.style.height=L+"px"),$()}function W(e){if(!M)return;const n=M.moved,t=(e.changedTouches?e.changedTouches[0].clientY:e.clientY)??M.y,a=M.y-t,o=M.h+a;M=null,B.style.display="none",document.body.style.userSelect="",s.style.transition="",s.style.height="",n&&(o<A?I():(L=Math.max(A,Math.min(window.innerHeight-50,o)),j(),S||z()),$())}Object.assign(B.style,{position:"fixed",inset:"0",zIndex:"9999",cursor:"ns-resize",display:"none"}),document.body.appendChild(B),x.addEventListener("click",e=>{e.stopPropagation(),R()}),d.addEventListener("mousedown",T,!0),document.addEventListener("mousemove",O),document.addEventListener("mouseup",W),d.addEventListener("touchstart",T,{passive:!1}),document.addEventListener("touchmove",O,{passive:!0}),document.addEventListener("touchend",W);let D=0,K=0;function N(e){f.textContent=e,f.style.display="block",z()}function U(){f.textContent="",f.style.display="none"}function F(){if(!Y._p||"fit"!==Y._mode)return;const e=Y._w,n=Y._h;if(!e||!n)return;const t=i.clientWidth,a=i.clientHeight,o=Math.min(t/e,a/n);c.style.transform=`scale(${o})`}function $(){if("fullscreen"===Y._mode?Y.size("max"):F(),H&&"function"==typeof H.windowResized)try{H.windowResized()}catch(e){N(String(e))}J&&J.resize()}window.addEventListener("mousemove",e=>{D=e.clientX,K=e.clientY},{passive:!0}),window.addEventListener("touchmove",e=>{e.touches.length>0&&(D=e.touches[0].clientX,K=e.touches[0].clientY)},{passive:!0}),window._pfMouse=()=>{const e=Y._p?Y._p.canvas:null;if(!e)return[0,0];const n=e.getBoundingClientRect(),t=Y._w/n.width,a=Y._h/n.height;return[(D-n.left)*t,(K-n.top)*a]},window.addEventListener("resize",$);let H=null;const Y=new Proxy({_p:null,_mode:"fit",_w:0,_h:0,_setP(e){this._p=e},size(e,n,t){if(!this._p)return;const a=t??void 0;"max"===e||null==e?(this._mode="fullscreen",this._w=i.clientWidth,this._h=i.clientHeight,void 0===a&&this._p.canvas?this._p.resizeCanvas(this._w,this._h):this._p.createCanvas(this._w,this._h,a),c.style.transform="scale(1)"):(this._mode="fit",this._w=Math.max(1,0|e),this._h=Math.max(1,0|n),void 0===a&&this._p.canvas?this._p.resizeCanvas(this._w,this._h):this._p.createCanvas(this._w,this._h,a),F())},noSmooth(){this._p?.noSmooth(),this._p?.canvas&&(this._p.canvas.style.imageRendering="pixelated")},smooth(){this._p?.smooth(),this._p?.canvas&&(this._p.canvas.style.imageRendering="auto")},sketchTitle(e){k.textContent=String(e)},getItem(e){try{return localStorage.getItem(e)}catch(e){return null}},storeItem(e,n){try{localStorage.setItem(e,String(n))}catch(e){}},removeItem(e){try{localStorage.removeItem(e)}catch(e){}},clearStorage(){try{localStorage.clear()}catch(e){}}},{get(e,n){if(n in e)return"function"==typeof e[n]?e[n].bind(e):e[n];if(e._p&&n in e._p){const t=e._p[n];return"function"==typeof t?t.bind(e._p):t}},set:(e,n,t)=>n.startsWith("_")?(e[n]=t,!0):(e._p&&(e._p[n]=t),!0)});function X(){if(Me(),H){try{H.remove()}catch(e){}H=null}l.innerHTML="",Y._p=null,Y._mode="fit",Y._w=0,Y._h=0,c.style.transform="scale(1)",k.textContent="Shift+Entrée → relancer  ·  Échap → ouvrir/fermer",ue&&(ue.destroy(),ue=null),me&&(me.destroy(),me=null),fe&&(fe.destroy(),fe=null),he&&(he.destroy(),he=null),_e&&(_e.destroy(),_e=null),ye&&(ye.destroy(),ye=null),be&&(be.destroy(),be=null),ge&&(ge.destroy(),ge=null),ve&&(ve.destroy(),ve=null),we&&(we.destroy(),we=null),xe&&(xe.destroy(),xe=null),ke&&(ke.destroy(),ke=null),Ee&&(Ee.destroy(),Ee=null),Ce&&(Ce.destroy(),Ce=null)}window.p5py=Y;let J=null,q=null;const G={},V=new Set;function Z(){E.innerHTML="",q=null;const n=e.filter(e=>!e.hidden);E.style.display=n.length<=1?"none":"",n.forEach(e=>{const n=document.createElement("button");n.className="pf-tab",n.dataset.tabId=e.id,n.textContent=e.label,e.readonly&&n.classList.add("pf-tab-readonly"),"markdown"===e.type&&n.classList.add("pf-tab-markdown"),n.addEventListener("click",()=>Q(e)),E.appendChild(n)}),n.length>0&&Q(n[0],!0)}function Q(e,n){if(n||q!==e)if(q=e,E.querySelectorAll(".pf-tab").forEach(n=>{n.classList.toggle("pf-tab-active",n.dataset.tabId===e.id)}),"markdown"===e.type){if(document.getElementById("pf-ace").style.display="none",C.style.display="block",window.marked){let n=marked.parse(e.starterCode);window.mermaid&&(n=n.replace(/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g,(e,n)=>`<div class="mermaid">${n.replace(/&amp;/g,"&").replace(/&lt;/g,"<").replace(/&gt;/g,">")}</div>`)),C.innerHTML=`<div class="pf-md-inner">${n}</div>`}else C.innerHTML=`<div class="pf-md-inner"><pre>${e.starterCode}</pre></div>`;window.mermaid&&mermaid.run({nodes:C.querySelectorAll(".mermaid")})}else document.getElementById("pf-ace").style.display="block",C.style.display="none",J&&G[e.id]&&(J.setSession(G[e.id]),J.setReadOnly(e.readonly),J.focus())}function ee(){let n=1;e.filter(e=>"python"===e.type).forEach(e=>{e.hidden||e.readonly||!G[e.id]?n+=e.code.split("\n").length:(G[e.id].setOption("firstLineNumber",n),n+=G[e.id].getLength())})}function ne(){Object.keys(G).forEach(e=>delete G[e]),e.filter(e=>!e.hidden&&"python"===e.type).forEach(e=>{const n=ace.createEditSession(e.code,"ace/mode/python");if(n.setUseWorker(!1),n.setTabSize(4),G[e.id]=n,!e.readonly){let e=null;n.on("change",()=>{null!==e&&(clearTimeout(e),V.delete(e)),e=setTimeout(()=>{V.delete(e),e=null,ae()},350),V.add(e),ee(),re()})}});const n=e.find(e=>!e.hidden&&"python"===e.type);J&&n&&G[n.id]&&(J.setSession(G[n.id]),J.setReadOnly(n.readonly),J.renderer.updateFull(!0)),ee()}function te(){!o.ace.startsWith("vendor")&&o.ace.startsWith("http")||ace.config.set("basePath",o.ace.replace(/\/[^/]+$/,"/")),J=ace.edit("pf-ace"),J.setTheme("ace/theme/monokai"),J.setOptions({fontSize:"15px",showPrintMargin:!1,wrap:!1,useWorker:!1,tabSize:4,enableBasicAutocompletion:!0,enableLiveAutocompletion:!0,enableSnippets:!0}),J.commands.addCommand({name:"pfRun",bindKey:{win:"Shift-Enter",mac:"Shift-Enter"},exec:()=>{J.completer?.popup?.isOpen||Se()}}),J.commands.addCommand({name:"pfClose",bindKey:{win:"Escape",mac:"Escape"},exec:I}),J.commands.addCommand({name:"pfSave",bindKey:{win:"Ctrl-S",mac:"Command-S"},exec:oe}),J.commands.addCommand({name:"pfReset",bindKey:{win:"Ctrl-R",mac:"Command-R"},exec:()=>{confirm("Réinitialiser ? Les modifications seront perdues.")&&ie()}}),ne(),Z(),re()}function ae(){const n={v:1,tabs:e.map(e=>({label:e.label,hidden:e.hidden,readonly:e.readonly,type:e.type,content:e.hidden||e.readonly||"python"!==e.type||!G[e.id]?e.code:G[e.id].getValue()}))};try{localStorage.setItem(a,JSON.stringify(n))}catch(e){}}function oe(){ae()}function re(){const n=e.some(e=>!e.hidden&&!e.readonly&&"python"===e.type&&G[e.id]&&G[e.id].getValue()!==e.starterCode);v.classList.toggle("pf-dirty",n)}function ie(){V.forEach(e=>clearTimeout(e)),V.clear();try{localStorage.removeItem(a)}catch(e){}e.forEach(e=>{if(e.label)try{localStorage.removeItem(a+":"+e.label.replace(/[^a-zA-Z0-9]/g,"_"))}catch(e){}});try{localStorage.removeItem(a+":Code")}catch(e){}e=t.map((e,n)=>({...e,id:"tab-"+n,code:e.starterCode})),ne(),Z(),re(),Se()}window.addEventListener("beforeunload",oe);let se=null,de=null;async function le(){return de||(de=(async()=>{const e={};if(o.pyodideIndex&&(e.indexURL=o.pyodideIndex),se=await loadPyodide(e),se.runPython("\nimport sys, types, js\nfrom js import p5py, _pfMouse\nfrom pyodide.ffi import JsProxy\n\n# ── Python builtins that must NOT be shadowed ──────────────────────\n_BLACKLIST = frozenset({\n 'abs','all','any','bin','bool','bytes','callable','chr','compile',\n 'delattr','dict','dir','divmod','enumerate','eval','exec',\n 'filter','float','format','frozenset','getattr','globals','hasattr',\n 'hash','help','hex','id','input','int','isinstance','issubclass',\n 'iter','len','list','locals','map','max','min','next','object',\n 'oct','open','ord','pow','print','property','range','repr',\n 'reversed','round','set','setattr','slice','sorted','staticmethod',\n 'str','sum','super','tuple','type','vars','zip',\n # p5 lifecycle hooks — user defines these, we don't import them\n 'setup','draw','preload',\n})\n\n# ── Introspect a hidden dummy p5 instance ─────────────────────────\n_dummy_node = js.document.createElement('div')\n_dummy = js.p5.new(lambda _: None, _dummy_node)\n\n_p5_functions = set() # names of callable JS members\n_p5_attributes = set() # names of scalar/readable members\n\nfor _n in dir(_dummy):\n if _n.startswith('_') or _n in _BLACKLIST:\n continue\n _v = getattr(_dummy, _n)\n if isinstance(_v, JsProxy):\n if callable(_v):\n _p5_functions.add(_n)\n # non-callable JsProxy (canvas, pixels…) → skip\n else:\n _p5_attributes.add(_n)\n\n# Read real initial values now, while dummy is still alive\n_attr_init = {}\nfor _n in _p5_attributes:\n try:\n _attr_init[_n] = getattr(_dummy, _n)\n except Exception:\n _attr_init[_n] = 0\n\n_dummy.remove()\ndel _dummy, _dummy_node\n\n# ── Build module ───────────────────────────────────────────────────\nm = types.ModuleType(\"p5\")\n\n# Generic function wrapper: delegates to live p5Bridge instance\nclass _FW:\n __slots__ = ('_n',)\n def __init__(self, n): self._n = n\n def __call__(self, *a): return getattr(p5py, self._n)(*a)\n def __repr__(self): return f'<p5 function {self._n}>'\n\nfor _n in _p5_functions:\n setattr(m, _n, _FW(_n))\n\n# ── Special overrides (our bridge has custom behaviour) ────────────\n# smooth/noSmooth exist on a real p5 instance so introspection finds\n# them — but our Proxy overrides them to also toggle CSS image-rendering.\n# size and sketchTitle are pyfrilet-only: NOT on a real p5 instance,\n# so introspection misses them — add them explicitly.\nfor _n in ('sketchTitle',):\n setattr(m, _n, _FW(_n))\n _p5_functions.add(_n) # keep __all__ consistent\n\n# size() calls _pf_refresh after resizing so width/height are immediately\n# correct in setup() — consistent with p5.js JS behaviour.\nclass _SizeWrapper:\n def __call__(self, *a):\n p5py.size(*a)\n _pf_refresh(_ns_ref[0])\n return _GetCanvasWrapper()()\n def __repr__(self): return '<p5 function size>'\nsetattr(m, 'size', _SizeWrapper())\nsetattr(m, 'createCanvas', m.size) # alias — createCanvas(...) == size(...)\n_p5_functions.add('size')\n_p5_functions.add('createCanvas')\n_ns_ref = [{}] # filled in by runCode before each exec\n\n# getCanvas() — returns the p5.Element wrapping the canvas,\n# so the user can call .drop(create_proxy(fn)), .mouseOver(), etc. directly like in JS.\nclass _GetCanvasWrapper:\n def __call__(self):\n p = p5py._p\n if p is None:\n raise RuntimeError('getCanvas() doit être appelé dans setup() ou après')\n p.canvas.id = '__pf_canvas__'\n return p.select('#__pf_canvas__')\n def __repr__(self): return '<p5 function getCanvas>'\nsetattr(m, 'getCanvas', _GetCanvasWrapper())\n_p5_functions.add('getCanvas')\n\n# mouseX / mouseY: override with our accurate coordinate calculator\n# (p5's own values are wrong when a CSS-transformed parent is used)\n_MOUSE_OVERRIDE = frozenset({'mouseX', 'mouseY'})\n\n# Initial values from the dummy instance — constants like WEBGL, DEGREES,\n# LEFT_ARROW… are correct from the very first setup() call.\nfor _n in _p5_attributes:\n if _n in _MOUSE_OVERRIDE:\n setattr(m, _n, 0.0)\n else:\n setattr(m, _n, _attr_init.get(_n, 0))\n\n# Build __all__ for import * — done later, after snake_case aliases are added\n\n# ── _pf_refresh: called before every event callback ───────────────\nimport re as _re\n\n# Pre-compute snake_case alias for each attribute — None if identical\n_attr_snake = {\n _k: (_re.sub(r'([A-Z])', lambda x: '_' + x.group(1).lower(), _k) or None)\n for _k in _p5_attributes\n}\n_attr_snake = {_k: (_s if _s != _k else None) for _k, _s in _attr_snake.items()}\n\n# Add snake_case names to _p5_attributes so __all__ and _pf_refresh cover them\nfor _k, _sk in list(_attr_snake.items()):\n if _sk:\n _p5_attributes.add(_sk)\n setattr(m, _sk, getattr(m, _k, 0)) # initial value mirrors camelCase\n _attr_snake[_sk] = None # snake name has no further alias\n\ndef _pf_refresh(ns):\n # accurate mouse coords (bypasses p5's stale CSS-transform offset)\n mx, my = _pfMouse()\n\n # update all known scalar attributes from live instance\n for _k in _p5_attributes:\n _sk = _attr_snake.get(_k)\n if _k in _MOUSE_OVERRIDE:\n _v = mx if _k in ('mouseX', 'mouse_x') else my\n elif _sk is None and _k not in _attr_snake:\n # pure snake_case entry — skip, updated via its camelCase counterpart\n continue\n else:\n try:\n _v = getattr(p5py, _k)\n except Exception:\n continue\n setattr(m, _k, _v)\n if _k in ns:\n ns[_k] = _v\n if _sk:\n setattr(m, _sk, _v)\n if _sk in ns:\n ns[_sk] = _v\n\nsys.modules[\"p5\"] = m\n\ndef _snake_to_camel(name):\n parts = name.split('_')\n return parts[0] + ''.join(p.capitalize() for p in parts[1:])\n\n# Pre-populate snake_case aliases so \"from p5 import no_fill\" works\nfor _camel in list(vars(m).keys()):\n _snake = _re.sub(r'([A-Z])', lambda x: '_' + x.group(1).lower(), _camel)\n if _snake != _camel and not hasattr(m, _snake):\n setattr(m, _snake, getattr(m, _camel))\n if _camel in _p5_functions:\n _p5_functions.add(_snake)\n\n# Rebuild __all__ now that snake_case aliases are included\nm.__all__ = sorted(_p5_functions | _p5_attributes)\n\ndef _p5_getattr(name):\n camel = _snake_to_camel(name)\n if camel != name:\n val = getattr(m, camel, None)\n if val is not None:\n return val\n raise AttributeError(f\"module 'p5' has no attribute '{name}'\")\n\nm.__getattr__ = _p5_getattr\n"),J){ce(se.runPython("list(m.__all__)").toJs())}})(),de)}function ce(e){const n=e.map(e=>({caption:e,value:e,meta:"p5",score:1e3})),t={getCompletions(e,t,a,o,r){r(null,o.length>0?n:[])}},a=ace.require("ace/ext/language_tools");a&&Array.isArray(a.completers)&&(a.completers=a.completers.filter(e=>!0!==e._pyfrilet)),t._pyfrilet=!0,J.completers=[...J.completers||[],t]}let pe=!1,me=null,fe=null,ue=null,he=null,_e=null,ye=null,be=null,ge=null,ve=null,we=null,xe=null,ke=null,Ee=null,Ce=null;async function Se(){if(pe)return;pe=!0,_.classList.add("pf-running"),U(),X(),se||(m.textContent="Initialisation de Pyodide…",p.style.display="flex");try{await le()}catch(e){return p.style.display="none",N("Erreur Pyodide : "+e),pe=!1,void _.classList.remove("pf-running")}p.style.display="none";const t=e.filter(e=>"python"===e.type).map(e=>e.hidden||e.readonly||!G[e.id]?e.code:G[e.id].getValue()).join("\n");try{m.textContent="Chargement des dépendances…",p.style.display="flex",await se.loadPackagesFromImports(t,{messageCallback:()=>{},checkIntegrity:n})}catch(e){console.warn("[pyfrilet] loadPackagesFromImports:",e)}p.style.display="none",se.globals.set("_USER_CODE",t);try{se.runPython("_ns = {}; exec(_USER_CODE, _ns, _ns)"),se.runPython("_ns_ref[0] = _ns")}catch(e){return N(String(e)),pe=!1,void _.classList.remove("pf-running")}let a,o,r,i,s,d,c,f,u,h,y,b,g,v;try{const e=(e,n)=>se.runPython(`_ns.get('${e}') or _ns.get('${n}')`);s=e("preload","preload"),a=e("setup","setup"),o=e("draw","draw"),r=e("mousePressed","mouse_pressed"),i=e("keyPressed","key_pressed"),d=e("mouseDragged","mouse_dragged"),c=e("mouseReleased","mouse_released"),f=e("mouseMoved","mouse_moved"),u=e("mouseWheel","mouse_wheel"),h=e("doubleClicked","double_clicked"),y=e("keyReleased","key_released"),b=e("touchStarted","touch_started"),g=e("touchMoved","touch_moved"),v=e("touchEnded","touch_ended")}catch(e){return N(String(e)),pe=!1,void _.classList.remove("pf-running")}if(!o)return N("Le script doit définir au moins une fonction draw()."),pe=!1,void _.classList.remove("pf-running");const{create_proxy:w}=se.pyimport("pyodide.ffi"),x=se.runPython("_ns.get('windowResized')"),k=se.globals.get("_pf_refresh"),E=se.globals.get("_ns"),C=e=>e?w(()=>{try{k(E),e()}catch(e){N(String(e))}}):null;ue=s?w(()=>{try{s()}catch(e){N(String(e))}}):null,me=a?w(()=>{try{a()}catch(e){N(String(e))}}):null;const S=200;fe=w(()=>{try{k(E);const e=performance.now();o(),performance.now()-e>S&&(X(),N(`draw() a mis plus de ${S} ms — sketch arrêté pour protéger le navigateur.`))}catch(e){N(String(e)),X()}}),he=C(r),_e=C(c),ye=C(d),be=C(f),ge=C(u),ve=C(h),we=C(i),xe=C(y),ke=C(b),Ee=C(g),Ce=C(v);const L=x?w(()=>{try{x()}catch(e){N(String(e))}}):null;let j=!1;H=new p5(e=>{Y._setP(e),ue&&(e.preload=()=>{ue()}),e.setup=()=>{me&&me(),e.canvas||Y.size(200,200),"function"==typeof e._updateMouseCoords&&e._updateMouseCoords({clientX:0,clientY:0}),e.windowResized(),j=!0},e.draw=()=>{j&&fe()},e.mousePressed=()=>{j&&he&&he()},e.mouseReleased=()=>{j&&_e&&_e()},e.mouseDragged=()=>{j&&ye&&ye()},e.mouseMoved=()=>{j&&be&&be()},e.mouseWheel=e=>{j&&ge&&ge()},e.doubleClicked=()=>{j&&ve&&ve()},e.keyPressed=()=>{j&&we&&we()},e.keyReleased=()=>{j&&xe&&xe()},ke&&(e.touchStarted=()=>{j&&ke()}),Ee&&(e.touchMoved=()=>{j&&Ee()}),Ce&&(e.touchEnded=()=>{j&&Ce()}),e.windowResized=()=>{"fullscreen"===Y._mode?Y.size("max"):F(),L&&L()}},l),pe=!1,_.classList.remove("pf-running")}const Le='<!doctype html>\n<html lang="fr">\n<head>\n <meta charset="utf-8">\n <meta name="viewport" content="width=device-width, initial-scale=1">\n <title>export</title>\n <script src="https://cdn.jsdelivr.net/npm/pyfrilet@0.5.1/pyfrilet.min.js"><\/script>\n</head>\n<body>\n\nFILLME-SCRIPTS\n\n</body>\n</html>';function je(){const n=e.map((e,n)=>{let t;t="python"!==e.type||e.hidden||e.readonly||!G[e.id]?e.code:G[e.id].getValue();const a=[],o="markdown"===e.type?"text/markdown":"text/python";null!==e.label&&a.push(`data-tab="${e.label.replace(/"/g,"&quot;")}"`),e.hidden&&a.push("data-hidden"),e.readonly&&a.push("data-readonly");return`<script type="${o}"${a.length?" "+a.join(" "):""}>\n${t.replace(/<\/script>/gi,"<\\/script>")}\n<\/script>`}).join("\n\n"),t=Le.replace("FILLME-SCRIPTS",n),a=new Blob([t],{type:"text/html;charset=utf-8"}),o=URL.createObjectURL(a),r=Object.assign(document.createElement("a"),{href:o,download:"sketch.html"});document.body.appendChild(r),r.click(),document.body.removeChild(r),URL.revokeObjectURL(o)}let ze=null,Ie=[];function Re(){const e=Y._p?.canvas;if(!e)return;const n=["video/webm;codecs=vp9","video/webm;codecs=vp8","video/webm"].find(e=>MediaRecorder.isTypeSupported(e))||"video/webm",t=e.captureStream();ze=new MediaRecorder(t,{mimeType:n}),Ie=[],ze.ondataavailable=e=>{e.data.size&&Ie.push(e.data)},ze.onstop=()=>{const e=new Blob(Ie,{type:n}),t=URL.createObjectURL(e),a=n.includes("webm")?"webm":"mp4";Object.assign(document.createElement("a"),{href:t,download:`sketch.${a}`}).click(),URL.revokeObjectURL(t),g.textContent="⏺",g.title="Enregistrer WebM",g.classList.remove("pf-recording"),ze=null},ze.start(),g.textContent="⏹",g.title="Arrêter l'enregistrement",g.classList.add("pf-recording")}function Me(){ze&&"inactive"!==ze.state&&ze.stop()}g.addEventListener("click",()=>{ze?Me():Re()}),_.addEventListener("click",()=>Se()),y.addEventListener("click",()=>{S?I():(L=window.innerHeight-32,j(),z())}),b.addEventListener("click",je);const Pe="https://codeberg.org/nopid/pyfrilet";function Ae(e){return new Promise((n,t)=>{const a=document.createElement("script");a.src=e,a.onload=n,a.onerror=()=>t(new Error("Impossible de charger : "+e)),document.head.appendChild(a)})}w.addEventListener("click",()=>window.open(Pe,"_blank")),v.addEventListener("click",()=>{confirm("Réinitialiser ? Les modifications seront perdues.")&&ie()}),window.addEventListener("keydown",e=>{const n=S&&J&&J.isFocused&&J.isFocused();if(n||!["ArrowLeft","ArrowRight","ArrowUp","ArrowDown"].includes(e.key)){if("Enter"===e.key&&e.shiftKey)return e.preventDefault(),void Se();if("Escape"===e.key){const t=document.querySelector(".ace_search");if(t&&"none"!==t.style.display)return e.preventDefault(),e.stopPropagation(),J.searchBox?J.searchBox.hide():t.style.display="none",void J.focus();if(n){const n=J.completer?.popup?.isOpen;if(n)return;return e.preventDefault(),e.stopPropagation(),void I()}return e.preventDefault(),e.stopPropagation(),void(S?I():z())}if(!n)return"s"!==e.key&&"S"!==e.key||!e.ctrlKey&&!e.metaKey?"r"!==e.key&&"R"!==e.key||!e.ctrlKey&&!e.metaKey||e.altKey?void 0:(e.preventDefault(),void(confirm("Réinitialiser ? Les modifications seront perdues.")&&ie())):(e.preventDefault(),void oe())}else e.preventDefault()},!0),(async()=>{m.textContent="Chargement des dépendances…",p.style.display="flex";try{if(await Ae(o.p5),o.marked){const e=document.createElement("link");e.rel="stylesheet",e.href=o.katexCss,document.head.appendChild(e),await Ae(o.marked),await Ae(o.katex),await Ae(o.markedKatex),await Ae(o.mermaid),marked.use(markedKatex({throwOnError:!1})),mermaid.initialize({startOnLoad:!1,theme:"neutral"})}await Ae(o.ace),await Ae(o.acePython),await Ae(o.aceMonokai),await Ae(o.aceLangTools),await Ae(o.aceSearchbox),await Ae(o.pyodide)}catch(e){return m.textContent="⚠ "+e.message,void(document.getElementById("pf-loader-bar").style.display="none")}te(),await Se(),p.style.display="none"})()}(C,k,x,w)})}();