pyfrilet 0.4.3 → 0.5.0

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.0",
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,50 @@ 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;
207
239
  }
208
- #pf-ace { position: absolute; inset: 0; }
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
+ #pf-markdown-view {
247
+ flex: 1;
248
+ overflow: auto;
249
+ padding: 14px 18px;
250
+ background: #1a1b2e;
251
+ color: #c0caf5;
252
+ font-size: 14px;
253
+ line-height: 1.6;
254
+ }
255
+ #pf-markdown-view h1,#pf-markdown-view h2,#pf-markdown-view h3 { color: #7aa2f7; }
256
+ #pf-markdown-view code { background: #24283b; padding: 1px 5px; border-radius: 3px; font-size: 13px; }
257
+ #pf-markdown-view pre code { display: block; padding: 10px; overflow: auto; }
209
258
 
210
259
  /* ── error panel (below editor, never overlaps ACE) ── */
211
260
  #pf-err {
@@ -219,12 +268,10 @@ html, body {
219
268
  white-space: pre-wrap;
220
269
  display: none;
221
270
  border-top: 1px solid rgba(247, 118, 142, .35);
222
- }
223
- `;
271
+ }`;
224
272
 
225
273
  /* ═══════════════════════════ MARKUP ═════════════════════════════════ */
226
- const MARKUP = `
227
- <div id="pf-root">
274
+ const MARKUP = `<div id="pf-root">
228
275
  <div id="pf-app" tabindex="-1">
229
276
  <div id="pf-viewport"><div id="pf-sketch"></div></div>
230
277
  <div id="pf-loader">
@@ -246,37 +293,50 @@ const MARKUP = `
246
293
  </div>
247
294
  </div>
248
295
  <div id="pf-editor-wrap">
296
+ <div id="pf-tabs"></div>
297
+ <div id="pf-markdown-view" style="display:none"></div>
249
298
  <div id="pf-ace"></div>
250
299
  </div>
251
300
  <pre id="pf-err"></pre>
252
301
  </div>
253
- </div>
254
- `;
302
+ </div>`;
255
303
 
256
304
  /* ═══════════════════════════ ENTRY POINT ════════════════════════════ */
257
305
  document.addEventListener('DOMContentLoaded', function () {
258
306
 
259
- const pyTag = document.querySelector('script[type="text/python"]')
260
- || document.querySelector('python');
261
- if (!pyTag) {
307
+ /* Collect all python/markdown script blocks in DOM order */
308
+ const allScripts = [
309
+ ...document.querySelectorAll(
310
+ 'script[type="text/python"], script[type="text/markdown"], python'
311
+ )
312
+ ];
313
+
314
+ if (allScripts.length === 0) {
262
315
  console.warn('[pyfrilet] No <script type="text/python"> or <python> tag found.');
263
316
  return;
264
317
  }
265
318
 
319
+ /* Read sources/vendor from the first script tag */
320
+ const firstScript = allScripts[0];
321
+ /* Config tag: prefer the <script src="pyfrilet.js"> itself, fallback to first python tag (retro-compat) */
322
+ const configTag = _pfScriptTag || firstScript;
323
+
266
324
  const sources = (
267
- pyTag.getAttribute('data-sources') ||
268
- pyTag.getAttribute('sources') ||
269
- 'local'
325
+ configTag.getAttribute('data-sources') ||
326
+ configTag.getAttribute('sources') ||
327
+ 'cdn'
270
328
  ).toLowerCase().trim();
271
329
 
272
330
  const vpRaw = (
273
- pyTag.getAttribute('data-vendor') ||
274
- pyTag.getAttribute('vendor') ||
331
+ configTag.getAttribute('data-vendor') ||
332
+ configTag.getAttribute('vendor') ||
275
333
  'vendor/'
276
334
  );
277
335
  const vp = vpRaw.replace(/\/?$/, '/'); /* ensure trailing slash */
278
336
 
279
337
  isCdn = sources === 'cdn';
338
+ const hasMarked = allScripts.some(el => el.getAttribute('type') === 'text/markdown');
339
+
280
340
  const URLS = isCdn ? {
281
341
  p5 : CDN.p5,
282
342
  pyodide : CDN.pyodide,
@@ -286,6 +346,11 @@ document.addEventListener('DOMContentLoaded', function () {
286
346
  aceMonokai : CDN.aceMonokai,
287
347
  aceLangTools: CDN.aceLangTools,
288
348
  aceSearchbox: CDN.aceSearchbox,
349
+ marked : hasMarked ? CDN.marked : null,
350
+ katexCss : hasMarked ? CDN.katexCss : null,
351
+ katex : hasMarked ? CDN.katex : null,
352
+ markedKatex : hasMarked ? CDN.markedKatex : null,
353
+ mermaid : hasMarked ? CDN.mermaid : null,
289
354
  } : {
290
355
  p5 : vp + 'p5.min.js',
291
356
  pyodide : vp + 'pyodide/pyodide.js',
@@ -295,20 +360,42 @@ document.addEventListener('DOMContentLoaded', function () {
295
360
  aceMonokai : vp + 'theme-monokai.min.js',
296
361
  aceLangTools: vp + 'ext-language_tools.min.js',
297
362
  aceSearchbox: vp + 'ext-searchbox.min.js',
363
+ marked : hasMarked ? vp + 'marked.min.js' : null,
364
+ katexCss : hasMarked ? vp + 'katex.min.css' : null,
365
+ katex : hasMarked ? vp + 'katex.min.js' : null,
366
+ markedKatex : hasMarked ? vp + 'marked-katex-extension.js' : null,
367
+ mermaid : hasMarked ? vp + 'mermaid.min.js' : null,
298
368
  };
299
369
 
300
- /* Dedent / strip leading blank line from embedded code */
301
- const starterCode = pyTag.textContent.replace(/^\n/, '');
370
+ const SK = 'pyfrilet:' + location.pathname;
371
+
372
+ /* Build tabs array from DOM */
373
+ const tabs = allScripts.map((el, i) => {
374
+ const type = el.getAttribute('type') === 'text/markdown' ? 'markdown' : 'python';
375
+ const hidden = el.hasAttribute('data-hidden');
376
+ const readonly = el.hasAttribute('data-readonly');
377
+ /* label: data-tab value, or null for hidden, or default label for untagged block */
378
+ let label = el.getAttribute('data-tab');
379
+ if (label === null && !hidden) label = allScripts.length === 1 ? 'Code' : `Bloc ${i + 1}`;
380
+
381
+ const rawCode = el.textContent.replace(/^\n/, ''); /* strip leading blank line */
382
+ const tabSK = SK + ':' + i;
383
+
384
+ /* Load saved code from localStorage for editable python tabs */
385
+ let code = rawCode;
386
+ if (type === 'python' && !hidden && !readonly) {
387
+ const saved = (() => { try { return localStorage.getItem(tabSK); } catch (e) { return null; } })();
388
+ if (saved && saved.trim()) code = saved;
389
+ }
302
390
 
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;
391
+ return { id: 'tab-' + i, label, hidden, readonly, type, starterCode: rawCode, code, sk: tabSK };
392
+ });
306
393
 
307
- main(initialCode, starterCode, SK, URLS);
394
+ main(tabs, SK, URLS);
308
395
  });
309
396
 
310
397
  /* ═══════════════════════════ MAIN ═══════════════════════════════════ */
311
- function main(initialCode, starterCode, SK, URLS) {
398
+ function main(tabs, SK, URLS) {
312
399
 
313
400
  /* ── inject styles + markup ── */
314
401
  const styleEl = document.createElement('style');
@@ -333,6 +420,8 @@ function main(initialCode, starterCode, SK, URLS) {
333
420
  const btnHelp = document.getElementById('pf-btn-help');
334
421
  const gripEl = document.getElementById('pf-grip');
335
422
  const hintEl = document.getElementById('pf-handle-hint');
423
+ const tabsEl = document.getElementById('pf-tabs');
424
+ const markdownEl = document.getElementById('pf-markdown-view');
336
425
 
337
426
  /* ─────────────────── DRAWER ─────────────────── */
338
427
  let drawerOpen = false;
@@ -475,6 +564,7 @@ function main(initialCode, starterCode, SK, URLS) {
475
564
  (_rawMouseY - r.top) * sy,
476
565
  ];
477
566
  };
567
+
478
568
  function showError(txt) { errEl.textContent = txt; errEl.style.display = 'block'; openDrawer(); }
479
569
  function clearError() { errEl.textContent = ''; errEl.style.display = 'none'; }
480
570
 
@@ -605,20 +695,85 @@ function main(initialCode, starterCode, SK, URLS) {
605
695
  if (touchEndedProxy) { touchEndedProxy.destroy(); touchEndedProxy = null; }
606
696
  }
607
697
 
608
- /* ─────────────────── ACE EDITOR ─────────────── */
609
- let aceInst = null;
698
+ /* ─────────────────── ACE EDITOR + TABS ─────────────── */
699
+ let aceInst = null;
700
+ let activeTab = null; /* current tab object */
701
+
702
+ /* Map tab.id → ACE EditSession (only for python editable visible tabs) */
703
+ const aceSessions = {};
704
+
705
+ /* ── Build tab bar ── */
706
+ function initTabs() {
707
+ const visibleTabs = tabs.filter(t => !t.hidden);
708
+ if (visibleTabs.length <= 1) {
709
+ tabsEl.style.display = 'none';
710
+ }
711
+
712
+ visibleTabs.forEach(tab => {
713
+ const btn = document.createElement('button');
714
+ btn.className = 'pf-tab';
715
+ btn.dataset.tabId = tab.id;
716
+ btn.textContent = tab.label;
717
+ if (tab.readonly) btn.classList.add('pf-tab-readonly');
718
+ if (tab.type === 'markdown') btn.classList.add('pf-tab-markdown');
719
+ btn.addEventListener('click', () => switchTab(tab));
720
+ tabsEl.appendChild(btn);
721
+ });
722
+
723
+ /* Activate first visible tab */
724
+ if (visibleTabs.length > 0) switchTab(visibleTabs[0], true);
725
+ }
726
+
727
+ function switchTab(tab, init) {
728
+ if (!init && activeTab === tab) return;
729
+ activeTab = tab;
730
+
731
+ /* Update tab button styles */
732
+ tabsEl.querySelectorAll('.pf-tab').forEach(btn => {
733
+ btn.classList.toggle('pf-tab-active', btn.dataset.tabId === tab.id);
734
+ });
735
+
736
+ if (tab.type === 'markdown') {
737
+ /* Show markdown, hide ACE */
738
+ document.getElementById('pf-ace').style.display = 'none';
739
+ markdownEl.style.display = 'block';
740
+ if (window.marked) {
741
+ let html = marked.parse(tab.starterCode);
742
+ /* marked HTML-escapes code block content — unescape mermaid blocks
743
+ so mermaid can parse the diagram syntax correctly */
744
+ if (window.mermaid) {
745
+ html = html.replace(
746
+ /<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g,
747
+ (_, code) => `<div class="mermaid">${
748
+ code.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
749
+ }</div>`
750
+ );
751
+ }
752
+ markdownEl.innerHTML = html;
753
+ } else {
754
+ markdownEl.innerHTML = `<pre>${tab.starterCode}</pre>`;
755
+ }
756
+ if (window.mermaid) mermaid.run({ nodes: markdownEl.querySelectorAll('.mermaid') });
757
+ } else {
758
+ /* Show ACE, hide markdown */
759
+ document.getElementById('pf-ace').style.display = 'block';
760
+ markdownEl.style.display = 'none';
761
+ if (aceInst && aceSessions[tab.id]) {
762
+ aceInst.setSession(aceSessions[tab.id]);
763
+ aceInst.setReadOnly(tab.readonly);
764
+ aceInst.focus();
765
+ }
766
+ }
767
+ }
610
768
 
611
769
  function initAce() {
612
- /* In local mode ACE cannot auto-detect where its dynamic modules live
613
- (searchbox, keybindings…), so we set basePath explicitly. */
770
+ /* In local mode ACE cannot auto-detect where its dynamic modules live */
614
771
  if (URLS.ace.startsWith('vendor') || !URLS.ace.startsWith('http')) {
615
772
  ace.config.set('basePath', URLS.ace.replace(/\/[^/]+$/, '/'));
616
773
  }
774
+
617
775
  aceInst = ace.edit('pf-ace');
618
- aceInst.session.setMode('ace/mode/python');
619
776
  aceInst.setTheme('ace/theme/monokai');
620
- aceInst.setValue(initialCode, -1);
621
- btnReset.classList.toggle('pf-dirty', initialCode !== starterCode);
622
777
  aceInst.setOptions({
623
778
  fontSize : '15px',
624
779
  showPrintMargin: false,
@@ -630,13 +785,31 @@ function main(initialCode, starterCode, SK, URLS) {
630
785
  enableSnippets : true,
631
786
  });
632
787
 
788
+ /* Create one ACE session per visible python tab */
789
+ tabs.filter(t => !t.hidden && t.type === 'python').forEach(tab => {
790
+ const session = ace.createEditSession(tab.code, 'ace/mode/python');
791
+ session.setUseWorker(false);
792
+ session.setTabSize(4);
793
+ aceSessions[tab.id] = session;
794
+
795
+ if (!tab.readonly) {
796
+ let saveTimer = null;
797
+ session.on('change', () => {
798
+ clearTimeout(saveTimer);
799
+ saveTimer = setTimeout(() => saveTab(tab), 350);
800
+ /* dirty indicator only for the active tab */
801
+ if (activeTab === tab) {
802
+ btnReset.classList.toggle('pf-dirty', session.getValue() !== tab.starterCode);
803
+ }
804
+ });
805
+ }
806
+ });
807
+
808
+ /* Keyboard shortcuts */
633
809
  aceInst.commands.addCommand({
634
810
  name: 'pfRun',
635
811
  bindKey: { win: 'Shift-Enter', mac: 'Shift-Enter' },
636
- exec: () => {
637
- if (aceInst.completer?.popup?.isOpen) return;
638
- runCode();
639
- },
812
+ exec: () => { if (aceInst.completer?.popup?.isOpen) return; runCode(); },
640
813
  });
641
814
  aceInst.commands.addCommand({
642
815
  name: 'pfClose',
@@ -652,24 +825,33 @@ function main(initialCode, starterCode, SK, URLS) {
652
825
  name: 'pfReset',
653
826
  bindKey: { win: 'Ctrl-R', mac: 'Command-R' },
654
827
  exec: () => {
655
- if (confirm('Réinitialiser le code ? Les modifications seront perdues.')) {
656
- aceInst.setValue(starterCode, -1);
828
+ if (!activeTab || activeTab.readonly || activeTab.type !== 'python') return;
829
+ if (confirm('Réinitialiser cet onglet ? Les modifications seront perdues.')) {
830
+ aceSessions[activeTab.id].setValue(activeTab.starterCode, -1);
657
831
  runCode();
658
832
  }
659
833
  },
660
834
  });
661
835
 
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
- });
836
+ /* Activate first visible python tab in ACE (or first python session if tab is markdown) */
837
+ const firstPythonTab = tabs.find(t => !t.hidden && t.type === 'python');
838
+ if (firstPythonTab && aceSessions[firstPythonTab.id]) {
839
+ aceInst.setSession(aceSessions[firstPythonTab.id]);
840
+ aceInst.setReadOnly(firstPythonTab.readonly);
841
+ }
842
+
843
+ initTabs();
844
+ }
845
+
846
+ function saveTab(tab) {
847
+ if (!tab || tab.readonly || tab.type !== 'python' || !aceSessions[tab.id]) return;
848
+ try { localStorage.setItem(tab.sk, aceSessions[tab.id].getValue()); } catch (e) {}
668
849
  }
669
850
 
670
851
  function saveCode() {
671
- try { localStorage.setItem(SK, aceInst ? aceInst.getValue() : initialCode); } catch (e) {}
852
+ tabs.forEach(tab => saveTab(tab));
672
853
  }
854
+
673
855
  window.addEventListener('beforeunload', saveCode);
674
856
 
675
857
  /* ─────────────────── PYODIDE ────────────────── */
@@ -931,7 +1113,14 @@ m.__getattr__ = _p5_getattr
931
1113
 
932
1114
  loaderEl.style.display = 'none';
933
1115
 
934
- const code = aceInst ? aceInst.getValue() : initialCode;
1116
+ /* Concatenate all python tabs in DOM order (hidden + visible) */
1117
+ const code = tabs
1118
+ .filter(t => t.type === 'python')
1119
+ .map(t => {
1120
+ if (!t.hidden && !t.readonly && aceSessions[t.id]) return aceSessions[t.id].getValue();
1121
+ return t.code; /* hidden or readonly: use original/starter code */
1122
+ })
1123
+ .join('\n');
935
1124
 
936
1125
  /* Auto-load any Pyodide-bundled packages the sketch imports. */
937
1126
  try {
@@ -1059,7 +1248,7 @@ m.__getattr__ = _p5_getattr
1059
1248
  }
1060
1249
 
1061
1250
  /* ─────────────────── DOWNLOAD ───────────────── */
1062
- const PYFRILET_CDN = 'https://cdn.jsdelivr.net/npm/pyfrilet@latest/pyfrilet.min.js';
1251
+ const PYFRILET_CDN = 'https://cdn.jsdelivr.net/npm/pyfrilet@0.5.0/pyfrilet.min.js';
1063
1252
 
1064
1253
  const STANDALONE_TEMPLATE = `<!doctype html>
1065
1254
  <html lang="fr">
@@ -1071,16 +1260,36 @@ m.__getattr__ = _p5_getattr
1071
1260
  </head>
1072
1261
  <body>
1073
1262
 
1074
- <script type="text/python" data-sources="cdn">
1075
- FILLME-PYTHON
1076
- <\/script>
1263
+ FILLME-SCRIPTS
1077
1264
 
1078
1265
  </body>
1079
1266
  </html>`;
1080
1267
 
1081
1268
  function download() {
1082
- const code = aceInst ? aceInst.getValue() : initialCode;
1083
- const html = STANDALONE_TEMPLATE.replace('FILLME-PYTHON', code);
1269
+ /* Reconstruct all script tags preserving structure, attributes, and current editor content */
1270
+ const scripts = tabs.map((tab, i) => {
1271
+ /* Get current content */
1272
+ let content;
1273
+ if (tab.type === 'python' && !tab.hidden && !tab.readonly && aceSessions[tab.id]) {
1274
+ content = aceSessions[tab.id].getValue();
1275
+ } else {
1276
+ content = tab.code;
1277
+ }
1278
+
1279
+ /* Rebuild attributes */
1280
+ const attrs = [];
1281
+ const scriptType = tab.type === 'markdown' ? 'text/markdown' : 'text/python';
1282
+ if (tab.label !== null) attrs.push(`data-tab="${tab.label.replace(/"/g, '&quot;')}"`);
1283
+ if (tab.hidden) attrs.push('data-hidden');
1284
+ if (tab.readonly) attrs.push('data-readonly');
1285
+
1286
+ const attrStr = attrs.length ? ' ' + attrs.join(' ') : '';
1287
+ /* Escape </script> inside content */
1288
+ const safe = content.replace(/<\/script>/gi, '<\\/script>');
1289
+ return `<script type="${scriptType}"${attrStr}>\n${safe}\n<\/script>`;
1290
+ }).join('\n\n');
1291
+
1292
+ const html = STANDALONE_TEMPLATE.replace('FILLME-SCRIPTS', scripts);
1084
1293
  const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
1085
1294
  const url = URL.createObjectURL(blob);
1086
1295
  const a = Object.assign(document.createElement('a'), { href: url, download: 'sketch.html' });
@@ -1090,20 +1299,6 @@ FILLME-PYTHON
1090
1299
  URL.revokeObjectURL(url);
1091
1300
  }
1092
1301
 
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
1302
  /* ── WebM recording ─────────────────────────────────────────────── */
1108
1303
  let mediaRecorder = null;
1109
1304
  let recChunks = [];
@@ -1142,13 +1337,29 @@ FILLME-PYTHON
1142
1337
  mediaRecorder ? stopRecording() : startRecording();
1143
1338
  });
1144
1339
 
1340
+ /* ─────────────────── BUTTON HANDLERS ────────── */
1341
+ btnRun.addEventListener('click', () => runCode());
1342
+
1343
+ /* Code button: open at full screen height, or close if already open */
1344
+ btnCode.addEventListener('click', () => {
1345
+ if (drawerOpen) {
1346
+ closeDrawer();
1347
+ } else {
1348
+ drawerH = window.innerHeight - 32;
1349
+ _applyDrawerH();
1350
+ openDrawer();
1351
+ }
1352
+ });
1353
+
1145
1354
  btnDl.addEventListener('click', download);
1355
+
1146
1356
  const HELP_URL = 'https://codeberg.org/nopid/pyfrilet';
1147
1357
  btnHelp.addEventListener('click', () => window.open(HELP_URL, '_blank'));
1148
1358
 
1149
1359
  btnReset.addEventListener('click', () => {
1150
- if (aceInst && confirm('Réinitialiser le code ? Les modifications seront perdues.')) {
1151
- aceInst.setValue(starterCode, -1);
1360
+ if (!activeTab || activeTab.readonly || activeTab.type !== 'python') return;
1361
+ if (aceSessions[activeTab.id] && confirm('Réinitialiser cet onglet ? Les modifications seront perdues.')) {
1362
+ aceSessions[activeTab.id].setValue(activeTab.starterCode, -1);
1152
1363
  runCode();
1153
1364
  }
1154
1365
  });
@@ -1210,9 +1421,11 @@ FILLME-PYTHON
1210
1421
  /* Ctrl/Cmd+R: reset code (prevent browser reload) */
1211
1422
  if ((ev.key === 'r' || ev.key === 'R') && (ev.ctrlKey || ev.metaKey) && !ev.altKey) {
1212
1423
  ev.preventDefault();
1213
- if (aceInst && confirm('Réinitialiser le code ? Les modifications seront perdues.')) {
1214
- aceInst.setValue(starterCode, -1);
1215
- runCode();
1424
+ if (activeTab && !activeTab.readonly && activeTab.type === 'python' && aceSessions[activeTab.id]) {
1425
+ if (confirm('Réinitialiser cet onglet ? Les modifications seront perdues.')) {
1426
+ aceSessions[activeTab.id].setValue(activeTab.starterCode, -1);
1427
+ runCode();
1428
+ }
1216
1429
  }
1217
1430
  return;
1218
1431
  }
@@ -1235,6 +1448,22 @@ FILLME-PYTHON
1235
1448
 
1236
1449
  try {
1237
1450
  await loadScript(URLS.p5);
1451
+ if (URLS.marked) {
1452
+ /* Load KaTeX CSS first (no JS dependency) */
1453
+ const katexLink = document.createElement('link');
1454
+ katexLink.rel = 'stylesheet';
1455
+ katexLink.href = URLS.katexCss;
1456
+ document.head.appendChild(katexLink);
1457
+ /* Then JS libs in order */
1458
+ await loadScript(URLS.marked);
1459
+ await loadScript(URLS.katex);
1460
+ await loadScript(URLS.markedKatex);
1461
+ await loadScript(URLS.mermaid);
1462
+ /* Configure marked: KaTeX extension */
1463
+ marked.use(markedKatex({ throwOnError: false }));
1464
+ /* Initialize mermaid (startOnLoad:false — we call run() manually) */
1465
+ mermaid.initialize({ startOnLoad: false, theme: 'dark' });
1466
+ }
1238
1467
  await loadScript(URLS.ace);
1239
1468
  await loadScript(URLS.acePython);
1240
1469
  await loadScript(URLS.aceMonokai);
@@ -1251,6 +1480,8 @@ FILLME-PYTHON
1251
1480
  await runCode();
1252
1481
  loaderEl.style.display = 'none';
1253
1482
  })();
1254
- }
1255
1483
 
1256
- })();
1484
+
1485
+ } /* end main() */
1486
+
1487
+ })(); /* 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",i="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/mode-python.min.js",r="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",u="https://cdn.jsdelivr.net/npm/marked-katex-extension@5.1.1/lib/index.umd.js",m="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js",f="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#pf-markdown-view {\n flex: 1;\n overflow: auto;\n padding: 14px 18px;\n background: #1a1b2e;\n color: #c0caf5;\n font-size: 14px;\n line-height: 1.6;\n}\n#pf-markdown-view h1,#pf-markdown-view h2,#pf-markdown-view h3 { color: #7aa2f7; }\n#pf-markdown-view code { background: #24283b; padding: 1px 5px; border-radius: 3px; font-size: 13px; }\n#pf-markdown-view pre code { display: block; padding: 10px; overflow: auto; }\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=_[0],b=e||y,g=(b.getAttribute("data-sources")||b.getAttribute("sources")||"cdn").toLowerCase().trim(),v=(b.getAttribute("data-vendor")||b.getAttribute("vendor")||"vendor/").replace(/\/?$/,"/");n="cdn"===g;const x=_.some(e=>"text/markdown"===e.getAttribute("type")),k=n?{p5:t,pyodide:a,pyodideIndex:null,ace:o,acePython:i,aceMonokai:r,aceLangTools:s,aceSearchbox:d,marked:x?l:null,katexCss:x?c:null,katex:x?p:null,markedKatex:x?u:null,mermaid:x?m:null}:{p5:v+"p5.min.js",pyodide:v+"pyodide/pyodide.js",pyodideIndex:v+"pyodide/",ace:v+"ace.min.js",acePython:v+"mode-python.min.js",aceMonokai:v+"theme-monokai.min.js",aceLangTools:v+"ext-language_tools.min.js",aceSearchbox:v+"ext-searchbox.min.js",marked:x?v+"marked.min.js":null,katexCss:x?v+"katex.min.css":null,katex:x?v+"katex.min.js":null,markedKatex:x?v+"marked-katex-extension.js":null,mermaid:x?v+"mermaid.min.js":null},w="pyfrilet:"+location.pathname;!function(e,t,a){const o=document.createElement("style");o.textContent=f,document.head.appendChild(o),document.body.innerHTML=h;const i=document.getElementById("pf-app"),r=document.getElementById("pf-drawer"),s=document.getElementById("pf-handle"),d=document.getElementById("pf-sketch"),l=document.getElementById("pf-viewport"),c=document.getElementById("pf-loader"),p=document.getElementById("pf-loader-msg"),u=document.getElementById("pf-err"),m=document.getElementById("pf-btn-run"),_=document.getElementById("pf-btn-code"),y=document.getElementById("pf-btn-dl"),b=document.getElementById("pf-btn-rec"),g=document.getElementById("pf-btn-reset"),v=document.getElementById("pf-btn-help"),x=document.getElementById("pf-grip"),k=document.getElementById("pf-handle-hint"),w=document.getElementById("pf-tabs"),E=document.getElementById("pf-markdown-view");let C=!1,S=Math.round(.56*window.innerHeight);function L(){document.documentElement.style.setProperty("--pf-drawer-h",S+"px")}function j(){C=!0,r.classList.add("pf-open"),_.classList.add("pf-active"),setTimeout(()=>{F(),V&&V.focus()},280)}function z(){C=!1,r.classList.remove("pf-open"),_.classList.remove("pf-active"),setTimeout(()=>{F();const e=H._p?.canvas;e&&e.removeAttribute("tabindex"),i.focus()},280)}function R(){C?z():j()}L();let I=null;const P=5,M=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;I={y:n,h:C?S:0,moved:!1},B.style.display="block",document.body.style.userSelect="none",e.cancelable&&e.preventDefault(),e.stopPropagation()}function A(e){if(!I)return;const n=e.touches?e.touches[0].clientY:e.clientY,t=I.y-n;if(Math.abs(t)>P&&(I.moved=!0),!I.moved)return;const a=Math.max(0,Math.min(window.innerHeight-50,I.h+t));a<M?(r.style.transition="none",r.style.height="32px"):(S=a,L(),C||j(),r.style.transition="none",r.style.height=S+"px"),F()}function O(e){if(!I)return;const n=I.moved,t=(e.changedTouches?e.changedTouches[0].clientY:e.clientY)??I.y,a=I.y-t,o=I.h+a;I=null,B.style.display="none",document.body.style.userSelect="",r.style.transition="",r.style.height="",n&&(o<M?z():(S=Math.max(M,Math.min(window.innerHeight-50,o)),L(),C||j()),F())}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()}),s.addEventListener("mousedown",T,!0),document.addEventListener("mousemove",A),document.addEventListener("mouseup",O),s.addEventListener("touchstart",T,{passive:!1}),document.addEventListener("touchmove",A,{passive:!0}),document.addEventListener("touchend",O);let W=0,D=0;function K(e){u.textContent=e,u.style.display="block",j()}function U(){u.textContent="",u.style.display="none"}function $(){if(!H._p||"fit"!==H._mode)return;const e=H._w,n=H._h;if(!e||!n)return;const t=i.clientWidth,a=i.clientHeight,o=Math.min(t/e,a/n);l.style.transform=`scale(${o})`}function F(){if("fullscreen"===H._mode?H.size("max"):$(),N&&"function"==typeof N.windowResized)try{N.windowResized()}catch(e){K(String(e))}V&&V.resize()}window.addEventListener("mousemove",e=>{W=e.clientX,D=e.clientY},{passive:!0}),window.addEventListener("touchmove",e=>{e.touches.length>0&&(W=e.touches[0].clientX,D=e.touches[0].clientY)},{passive:!0}),window._pfMouse=()=>{const e=H._p?H._p.canvas:null;if(!e)return[0,0];const n=e.getBoundingClientRect(),t=H._w/n.width,a=H._h/n.height;return[(W-n.left)*t,(D-n.top)*a]},window.addEventListener("resize",F);let N=null;const H=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),l.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),$())},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 Y(){if(Se(),N){try{N.remove()}catch(e){}N=null}d.innerHTML="",H._p=null,H._mode="fit",H._w=0,H._h=0,l.style.transform="scale(1)",k.textContent="Shift+Entrée → relancer  ·  Échap → ouvrir/fermer",de&&(de.destroy(),de=null),re&&(re.destroy(),re=null),se&&(se.destroy(),se=null),le&&(le.destroy(),le=null),ce&&(ce.destroy(),ce=null),pe&&(pe.destroy(),pe=null),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)}window.p5py=H;let V=null,X=null;const J={};function q(){const n=e.filter(e=>!e.hidden);n.length<=1&&(w.style.display="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",()=>G(e)),w.appendChild(n)}),n.length>0&&G(n[0],!0)}function G(e,n){if(n||X!==e)if(X=e,w.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",E.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>`)),E.innerHTML=n}else E.innerHTML=`<pre>${e.starterCode}</pre>`;window.mermaid&&mermaid.run({nodes:E.querySelectorAll(".mermaid")})}else document.getElementById("pf-ace").style.display="block",E.style.display="none",V&&J[e.id]&&(V.setSession(J[e.id]),V.setReadOnly(e.readonly),V.focus())}function Z(){!a.ace.startsWith("vendor")&&a.ace.startsWith("http")||ace.config.set("basePath",a.ace.replace(/\/[^/]+$/,"/")),V=ace.edit("pf-ace"),V.setTheme("ace/theme/monokai"),V.setOptions({fontSize:"15px",showPrintMargin:!1,wrap:!1,useWorker:!1,tabSize:4,enableBasicAutocompletion:!0,enableLiveAutocompletion:!0,enableSnippets:!0}),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),J[e.id]=n,!e.readonly){let t=null;n.on("change",()=>{clearTimeout(t),t=setTimeout(()=>Q(e),350),X===e&&g.classList.toggle("pf-dirty",n.getValue()!==e.starterCode)})}}),V.commands.addCommand({name:"pfRun",bindKey:{win:"Shift-Enter",mac:"Shift-Enter"},exec:()=>{V.completer?.popup?.isOpen||ve()}}),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:ee}),V.commands.addCommand({name:"pfReset",bindKey:{win:"Ctrl-R",mac:"Command-R"},exec:()=>{X&&!X.readonly&&"python"===X.type&&confirm("Réinitialiser cet onglet ? Les modifications seront perdues.")&&(J[X.id].setValue(X.starterCode,-1),ve())}});const n=e.find(e=>!e.hidden&&"python"===e.type);n&&J[n.id]&&(V.setSession(J[n.id]),V.setReadOnly(n.readonly)),q()}function Q(e){if(e&&!e.readonly&&"python"===e.type&&J[e.id])try{localStorage.setItem(e.sk,J[e.id].getValue())}catch(e){}}function ee(){e.forEach(e=>Q(e))}window.addEventListener("beforeunload",ee);let ne=null,te=null;async function ae(){return te||(te=(async()=>{const e={};if(a.pyodideIndex&&(e.indexURL=a.pyodideIndex),ne=await loadPyodide(e),ne.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){oe(ne.runPython("list(m.__all__)").toJs())}})(),te)}function oe(e){const n=e.map(e=>({caption:e,value:e,meta:"p5",score:1e3})),t={getCompletions(e,t,a,o,i){i(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,V.completers=[...V.completers||[],t]}let ie=!1,re=null,se=null,de=null,le=null,ce=null,pe=null,ue=null,me=null,fe=null,he=null,_e=null,ye=null,be=null,ge=null;async function ve(){if(ie)return;ie=!0,m.classList.add("pf-running"),U(),Y(),ne||(p.textContent="Initialisation de Pyodide…",c.style.display="flex");try{await ae()}catch(e){return c.style.display="none",K("Erreur Pyodide : "+e),ie=!1,void m.classList.remove("pf-running")}c.style.display="none";const t=e.filter(e=>"python"===e.type).map(e=>e.hidden||e.readonly||!J[e.id]?e.code:J[e.id].getValue()).join("\n");try{p.textContent="Chargement des dépendances…",c.style.display="flex",await ne.loadPackagesFromImports(t,{messageCallback:()=>{},checkIntegrity:n})}catch(e){console.warn("[pyfrilet] loadPackagesFromImports:",e)}c.style.display="none",ne.globals.set("_USER_CODE",t);try{ne.runPython("_ns = {}; exec(_USER_CODE, _ns, _ns)"),ne.runPython("_ns_ref[0] = _ns")}catch(e){return K(String(e)),ie=!1,void m.classList.remove("pf-running")}let a,o,i,r,s,l,u,f,h,_,y,b,g,v;try{const e=(e,n)=>ne.runPython(`_ns.get('${e}') or _ns.get('${n}')`);s=e("preload","preload"),a=e("setup","setup"),o=e("draw","draw"),i=e("mousePressed","mouse_pressed"),r=e("keyPressed","key_pressed"),l=e("mouseDragged","mouse_dragged"),u=e("mouseReleased","mouse_released"),f=e("mouseMoved","mouse_moved"),h=e("mouseWheel","mouse_wheel"),_=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 K(String(e)),ie=!1,void m.classList.remove("pf-running")}if(!o)return K("Le script doit définir au moins une fonction draw()."),ie=!1,void m.classList.remove("pf-running");const{create_proxy:x}=ne.pyimport("pyodide.ffi"),k=ne.runPython("_ns.get('windowResized')"),w=ne.globals.get("_pf_refresh"),E=ne.globals.get("_ns"),C=e=>e?x(()=>{try{w(E),e()}catch(e){K(String(e))}}):null;de=s?x(()=>{try{s()}catch(e){K(String(e))}}):null,re=a?x(()=>{try{a()}catch(e){K(String(e))}}):null;const S=200;se=x(()=>{try{w(E);const e=performance.now();o(),performance.now()-e>S&&(Y(),K(`draw() a mis plus de ${S} ms — sketch arrêté pour protéger le navigateur.`))}catch(e){K(String(e)),Y()}}),le=C(i),ce=C(u),pe=C(l),ue=C(f),me=C(h),fe=C(_),he=C(r),_e=C(y),ye=C(b),be=C(g),ge=C(v);const L=k?x(()=>{try{k()}catch(e){K(String(e))}}):null;let j=!1;N=new p5(e=>{H._setP(e),de&&(e.preload=()=>{de()}),e.setup=()=>{re&&re(),e.canvas||H.size(200,200),"function"==typeof e._updateMouseCoords&&e._updateMouseCoords({clientX:0,clientY:0}),e.windowResized(),j=!0},e.draw=()=>{j&&se()},e.mousePressed=()=>{j&&le&&le()},e.mouseReleased=()=>{j&&ce&&ce()},e.mouseDragged=()=>{j&&pe&&pe()},e.mouseMoved=()=>{j&&ue&&ue()},e.mouseWheel=e=>{j&&me&&me()},e.doubleClicked=()=>{j&&fe&&fe()},e.keyPressed=()=>{j&&he&&he()},e.keyReleased=()=>{j&&_e&&_e()},ye&&(e.touchStarted=()=>{j&&ye()}),be&&(e.touchMoved=()=>{j&&be()}),ge&&(e.touchEnded=()=>{j&&ge()}),e.windowResized=()=>{"fullscreen"===H._mode?H.size("max"):$(),L&&L()}},d),ie=!1,m.classList.remove("pf-running")}const xe='<!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.0/pyfrilet.min.js"><\/script>\n</head>\n<body>\n\nFILLME-SCRIPTS\n\n</body>\n</html>';function ke(){const n=e.map((e,n)=>{let t;t="python"!==e.type||e.hidden||e.readonly||!J[e.id]?e.code:J[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=xe.replace("FILLME-SCRIPTS",n),a=new Blob([t],{type:"text/html;charset=utf-8"}),o=URL.createObjectURL(a),i=Object.assign(document.createElement("a"),{href:o,download:"sketch.html"});document.body.appendChild(i),i.click(),document.body.removeChild(i),URL.revokeObjectURL(o)}let we=null,Ee=[];function Ce(){const e=H._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();we=new MediaRecorder(t,{mimeType:n}),Ee=[],we.ondataavailable=e=>{e.data.size&&Ee.push(e.data)},we.onstop=()=>{const e=new Blob(Ee,{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),b.textContent="⏺",b.title="Enregistrer WebM",b.classList.remove("pf-recording"),we=null},we.start(),b.textContent="⏹",b.title="Arrêter l'enregistrement",b.classList.add("pf-recording")}function Se(){we&&"inactive"!==we.state&&we.stop()}b.addEventListener("click",()=>{we?Se():Ce()}),m.addEventListener("click",()=>ve()),_.addEventListener("click",()=>{C?z():(S=window.innerHeight-32,L(),j())}),y.addEventListener("click",ke);const Le="https://codeberg.org/nopid/pyfrilet";function je(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)})}v.addEventListener("click",()=>window.open(Le,"_blank")),g.addEventListener("click",()=>{X&&!X.readonly&&"python"===X.type&&J[X.id]&&confirm("Réinitialiser cet onglet ? Les modifications seront perdues.")&&(J[X.id].setValue(X.starterCode,-1),ve())}),window.addEventListener("keydown",e=>{const n=C&&V&&V.isFocused&&V.isFocused();if(n||!["ArrowLeft","ArrowRight","ArrowUp","ArrowDown"].includes(e.key)){if("Enter"===e.key&&e.shiftKey)return e.preventDefault(),void ve();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(C?z():j())}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(X&&!X.readonly&&"python"===X.type&&J[X.id]&&confirm("Réinitialiser cet onglet ? Les modifications seront perdues.")&&(J[X.id].setValue(X.starterCode,-1),ve()))):(e.preventDefault(),void ee())}else e.preventDefault()},!0),(async()=>{p.textContent="Chargement des dépendances…",c.style.display="flex";try{if(await je(a.p5),a.marked){const e=document.createElement("link");e.rel="stylesheet",e.href=a.katexCss,document.head.appendChild(e),await je(a.marked),await je(a.katex),await je(a.markedKatex),await je(a.mermaid),marked.use(markedKatex({throwOnError:!1})),mermaid.initialize({startOnLoad:!1,theme:"dark"})}await je(a.ace),await je(a.acePython),await je(a.aceMonokai),await je(a.aceLangTools),await je(a.aceSearchbox),await je(a.pyodide)}catch(e){return p.textContent="⚠ "+e.message,void(document.getElementById("pf-loader-bar").style.display="none")}Z(),await ve(),c.style.display="none"})()}(_.map((e,n)=>{const t="text/markdown"===e.getAttribute("type")?"markdown":"python",a=e.hasAttribute("data-hidden"),o=e.hasAttribute("data-readonly");let i=e.getAttribute("data-tab");null!==i||a||(i=1===_.length?"Code":`Bloc ${n+1}`);const r=e.textContent.replace(/^\n/,""),s=w+":"+n;let d=r;if("python"===t&&!a&&!o){const e=(()=>{try{return localStorage.getItem(s)}catch(e){return null}})();e&&e.trim()&&(d=e)}return{id:"tab-"+n,label:i,hidden:a,readonly:o,type:t,starterCode:r,code:d,sk:s}}),0,k)})}();