pyfrilet 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/pyfrilet.js ADDED
@@ -0,0 +1,1062 @@
1
+ /**
2
+ * pyfrilet.js — Python/p5 in-page runtime with slide-up editor
3
+ *
4
+ * Usage in your HTML:
5
+ * <script src="pyfrilet.js"></script>
6
+ * <script type="text/python" data-sources="local" data-vendor="vendor/">
7
+ * from p5 import *
8
+ * def setup(): size(400, 400)
9
+ * def draw(): background(0)
10
+ * </script>
11
+ *
12
+ * data-sources : "local" (default) | "cdn"
13
+ * data-vendor : path to local vendor folder, default "vendor/"
14
+ *
15
+ * Keyboard shortcuts:
16
+ * Shift+Enter → run code (also closes editor)
17
+ * Escape → close editor without running
18
+ * Ctrl+S → save to localStorage
19
+ * Ctrl+R → reset to starter code
20
+ */
21
+ (function () {
22
+ 'use strict';
23
+
24
+ /* ── capture script element immediately (for self-fetch in download) ── */
25
+ const _scriptEl = document.currentScript;
26
+ const _scriptSrc = _scriptEl ? (_scriptEl.src || '') : '';
27
+
28
+ /* ═══════════════════════════ CDN URLS ═══════════════════════════════ */
29
+ const CDN = {
30
+ p5 : 'https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.4/p5.min.js',
31
+ pyodide : 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js',
32
+ ace : 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/ace.min.js',
33
+ acePython : 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/mode-python.min.js',
34
+ aceMonokai : 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/theme-monokai.min.js',
35
+ aceLangTools: 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/ext-language_tools.min.js',
36
+ aceSearchbox: 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/ext-searchbox.min.js',
37
+ };
38
+
39
+ /* ═══════════════════════════ STYLES ═════════════════════════════════ */
40
+ const STYLES = `
41
+ html, body {
42
+ height: 100%; margin: 0; overflow: hidden;
43
+ background: #111;
44
+ }
45
+ #pf-root {
46
+ position: fixed; inset: 0;
47
+ display: flex; flex-direction: column;
48
+ font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
49
+ }
50
+
51
+ /* ── app area ── */
52
+ #pf-app {
53
+ flex: 1; min-height: 0;
54
+ position: relative;
55
+ background: #111;
56
+ display: flex; align-items: center; justify-content: center;
57
+ overflow: hidden;
58
+ }
59
+ #pf-viewport {
60
+ transform-origin: 50% 50%;
61
+ will-change: transform;
62
+ }
63
+ #pf-viewport canvas {
64
+ display: block;
65
+ outline: none;
66
+ }
67
+ #pf-loader {
68
+ position: absolute; inset: 0;
69
+ display: flex; flex-direction: column;
70
+ align-items: center; justify-content: center;
71
+ gap: 14px;
72
+ background: #111;
73
+ color: #565f89;
74
+ font-size: 13px;
75
+ z-index: 50;
76
+ pointer-events: none;
77
+ }
78
+ #pf-loader-bar {
79
+ width: 160px; height: 2px;
80
+ background: #2a2c3e;
81
+ border-radius: 2px;
82
+ overflow: hidden;
83
+ }
84
+ #pf-loader-bar::after {
85
+ content: '';
86
+ display: block;
87
+ height: 100%;
88
+ width: 40%;
89
+ background: #7aa2f7;
90
+ border-radius: 2px;
91
+ animation: pf-slide 1.2s ease-in-out infinite;
92
+ }
93
+ @keyframes pf-slide {
94
+ 0% { transform: translateX(-100%); }
95
+ 100% { transform: translateX(350%); }
96
+ }
97
+
98
+ /* ── drawer (slide-up editor panel) ── */
99
+ #pf-drawer {
100
+ flex-shrink: 0;
101
+ display: flex;
102
+ flex-direction: column;
103
+ background: #1a1b26;
104
+ height: 32px; /* collapsed = handle only */
105
+ transition: height 0.26s cubic-bezier(.4, 0, .2, 1);
106
+ overflow: hidden;
107
+ /* shadow cast upward onto the app */
108
+ box-shadow: 0 -4px 20px rgba(0,0,0,.55);
109
+ }
110
+ #pf-drawer.pf-open {
111
+ height: var(--pf-drawer-h, 56vh);
112
+ }
113
+
114
+ /* ── handle bar ── */
115
+ #pf-handle {
116
+ height: 32px;
117
+ min-height: 32px;
118
+ display: flex;
119
+ align-items: center;
120
+ padding: 0 8px 0 6px;
121
+ background: #24283b;
122
+ border-top: 1px solid #3d4166;
123
+ cursor: ns-resize;
124
+ user-select: none;
125
+ gap: 6px;
126
+ flex-shrink: 0;
127
+ }
128
+ /* grip zone: clickable to toggle, draggable to resize */
129
+ #pf-grip {
130
+ display: flex;
131
+ flex-direction: column;
132
+ gap: 3px;
133
+ padding: 5px 6px;
134
+ flex-shrink: 0;
135
+ opacity: .5;
136
+ border-radius: 4px;
137
+ transition: opacity .15s, background .15s;
138
+ cursor: pointer;
139
+ }
140
+ #pf-grip:hover { opacity: .85; background: rgba(255,255,255,.06); }
141
+ #pf-grip span {
142
+ display: block;
143
+ width: 16px; height: 2px;
144
+ background: #a9b1d6;
145
+ border-radius: 1px;
146
+ }
147
+ #pf-handle-hint {
148
+ flex: 1;
149
+ color: #565f89;
150
+ font-size: 10px;
151
+ overflow: hidden;
152
+ text-overflow: ellipsis;
153
+ white-space: nowrap;
154
+ }
155
+ #pf-handle-btns {
156
+ display: flex;
157
+ gap: 4px;
158
+ flex-shrink: 0;
159
+ }
160
+ .pf-btn {
161
+ height: 26px;
162
+ min-width: 26px;
163
+ padding: 0 5px;
164
+ border: 0; border-radius: 5px;
165
+ cursor: pointer;
166
+ display: flex; align-items: center; justify-content: center;
167
+ font-size: 13px; line-height: 1;
168
+ white-space: nowrap;
169
+ transition: background .15s, transform .1s, opacity .15s;
170
+ outline: none;
171
+ box-sizing: border-box;
172
+ }
173
+ .pf-btn:active { transform: scale(.88); }
174
+ .pf-btn:focus-visible { outline: 2px solid #7aa2f7; outline-offset: 1px; }
175
+
176
+ #pf-btn-run { background: #1a6b3a; color: #9ece6a; font-size: 11px; }
177
+ #pf-btn-run:hover { background: #1f8447; color: #b9f27a; }
178
+ #pf-btn-run.pf-running { opacity: .5; cursor: not-allowed; }
179
+
180
+ #pf-btn-code { background: #2a2c3e; color: #7aa2f7; font-size: 14px; }
181
+ #pf-btn-code:hover { background: #3d4166; color: #c0caf5; }
182
+ #pf-btn-code.pf-active { background: #3d4166; color: #e0af68; }
183
+
184
+ #pf-btn-dl { background: #2a2c3e; color: #9d7cd8; font-size: 14px; }
185
+ #pf-btn-dl:hover { background: #3d4166; color: #bb9af7; }
186
+
187
+ #pf-btn-reset { background: #2a2c3e; color: #e0af68; font-size: 16px; }
188
+ #pf-btn-reset:hover { background: #3d4166; color: #ffc777; }
189
+
190
+ /* ── editor area inside drawer ── */
191
+ #pf-editor-wrap {
192
+ flex: 1;
193
+ min-height: 80px;
194
+ position: relative;
195
+ }
196
+ #pf-ace { position: absolute; inset: 0; }
197
+
198
+ /* ── error panel (below editor, never overlaps ACE) ── */
199
+ #pf-err {
200
+ flex-shrink: 0;
201
+ max-height: 120px;
202
+ overflow: auto;
203
+ margin: 0; padding: 8px 13px;
204
+ font-size: 11.5px; line-height: 1.45;
205
+ background: rgba(13, 3, 3, .95);
206
+ color: #f7768e;
207
+ white-space: pre-wrap;
208
+ display: none;
209
+ border-top: 1px solid rgba(247, 118, 142, .35);
210
+ }
211
+ `;
212
+
213
+ /* ═══════════════════════════ MARKUP ═════════════════════════════════ */
214
+ const MARKUP = `
215
+ <div id="pf-root">
216
+ <div id="pf-app">
217
+ <div id="pf-viewport"><div id="pf-sketch"></div></div>
218
+ <div id="pf-loader">
219
+ <span id="pf-loader-msg">Chargement…</span>
220
+ <div id="pf-loader-bar"></div>
221
+ </div>
222
+ </div>
223
+ <div id="pf-drawer">
224
+ <div id="pf-handle">
225
+ <div id="pf-grip" title="Clic → ouvrir/fermer"><span></span><span></span><span></span></div>
226
+ <span id="pf-handle-hint">Clic ☰ → ouvrir/fermer &nbsp;·&nbsp; Shift+Entrée → relancer</span>
227
+ <div id="pf-handle-btns">
228
+ <button class="pf-btn" id="pf-btn-run" title="Relancer (Shift+Entrée)">&#9654;</button>
229
+ <button class="pf-btn" id="pf-btn-code" title="Éditeur plein écran">&#9999;&#xFE0F;</button>
230
+ <button class="pf-btn" id="pf-btn-dl" title="Télécharger HTML autonome">&#128190;</button>
231
+ <button class="pf-btn" id="pf-btn-help" title="Aide">?</button>
232
+ <button class="pf-btn" id="pf-btn-reset" title="Réinitialiser le code (Ctrl+R)">&#8635;</button>
233
+ </div>
234
+ </div>
235
+ <div id="pf-editor-wrap">
236
+ <div id="pf-ace"></div>
237
+ </div>
238
+ <pre id="pf-err"></pre>
239
+ </div>
240
+ </div>
241
+ `;
242
+
243
+ /* ═══════════════════════════ ENTRY POINT ════════════════════════════ */
244
+ document.addEventListener('DOMContentLoaded', function () {
245
+
246
+ const pyTag = document.querySelector('script[type="text/python"]')
247
+ || document.querySelector('python');
248
+ if (!pyTag) {
249
+ console.warn('[pyfrilet] No <script type="text/python"> or <python> tag found.');
250
+ return;
251
+ }
252
+
253
+ const sources = (
254
+ pyTag.getAttribute('data-sources') ||
255
+ pyTag.getAttribute('sources') ||
256
+ 'local'
257
+ ).toLowerCase().trim();
258
+
259
+ const vpRaw = (
260
+ pyTag.getAttribute('data-vendor') ||
261
+ pyTag.getAttribute('vendor') ||
262
+ 'vendor/'
263
+ );
264
+ const vp = vpRaw.replace(/\/?$/, '/'); /* ensure trailing slash */
265
+
266
+ const URLS = sources === 'cdn' ? {
267
+ p5 : CDN.p5,
268
+ pyodide : CDN.pyodide,
269
+ pyodideIndex: null,
270
+ ace : CDN.ace,
271
+ acePython : CDN.acePython,
272
+ aceMonokai : CDN.aceMonokai,
273
+ aceLangTools: CDN.aceLangTools,
274
+ aceSearchbox: CDN.aceSearchbox,
275
+ } : {
276
+ p5 : vp + 'p5.min.js',
277
+ pyodide : vp + 'pyodide/pyodide.js',
278
+ pyodideIndex: vp + 'pyodide/',
279
+ ace : vp + 'ace.min.js',
280
+ acePython : vp + 'mode-python.min.js',
281
+ aceMonokai : vp + 'theme-monokai.min.js',
282
+ aceLangTools: vp + 'ext-language_tools.min.js',
283
+ aceSearchbox: vp + 'ext-searchbox.min.js',
284
+ };
285
+
286
+ /* Dedent / strip leading blank line from embedded code */
287
+ const starterCode = pyTag.textContent.replace(/^\n/, '');
288
+
289
+ const SK = 'pyfrilet:' + location.pathname;
290
+ const saved = (() => { try { return localStorage.getItem(SK); } catch (e) { return null; } })();
291
+ const initialCode = (saved && saved.trim()) ? saved : starterCode;
292
+
293
+ main(initialCode, starterCode, SK, URLS);
294
+ });
295
+
296
+ /* ═══════════════════════════ MAIN ═══════════════════════════════════ */
297
+ function main(initialCode, starterCode, SK, URLS) {
298
+
299
+ /* ── inject styles + markup ── */
300
+ const styleEl = document.createElement('style');
301
+ styleEl.textContent = STYLES;
302
+ document.head.appendChild(styleEl);
303
+ document.body.innerHTML = MARKUP;
304
+
305
+ /* ── element refs ── */
306
+ const appEl = document.getElementById('pf-app');
307
+ const drawerEl = document.getElementById('pf-drawer');
308
+ const handleEl = document.getElementById('pf-handle');
309
+ const sketchEl = document.getElementById('pf-sketch');
310
+ const viewEl = document.getElementById('pf-viewport');
311
+ const loaderEl = document.getElementById('pf-loader');
312
+ const loaderMsg = document.getElementById('pf-loader-msg');
313
+ const errEl = document.getElementById('pf-err');
314
+ const btnRun = document.getElementById('pf-btn-run');
315
+ const btnCode = document.getElementById('pf-btn-code');
316
+ const btnDl = document.getElementById('pf-btn-dl');
317
+ const btnReset = document.getElementById('pf-btn-reset');
318
+ const btnHelp = document.getElementById('pf-btn-help');
319
+ const gripEl = document.getElementById('pf-grip');
320
+ const hintEl = document.getElementById('pf-handle-hint');
321
+
322
+ /* ─────────────────── DRAWER ─────────────────── */
323
+ let drawerOpen = false;
324
+ let drawerH = Math.round(window.innerHeight * 0.56); /* px, user-adjustable */
325
+
326
+ function _applyDrawerH() {
327
+ document.documentElement.style.setProperty('--pf-drawer-h', drawerH + 'px');
328
+ }
329
+ _applyDrawerH();
330
+
331
+ function openDrawer() {
332
+ drawerOpen = true;
333
+ drawerEl.classList.add('pf-open');
334
+ btnCode.classList.add('pf-active');
335
+ /* wait for CSS transition to finish before notifying p5 */
336
+ setTimeout(() => { notifyResize(); if (aceInst) aceInst.focus(); }, 280);
337
+ }
338
+ function closeDrawer() {
339
+ drawerOpen = false;
340
+ drawerEl.classList.remove('pf-open');
341
+ btnCode.classList.remove('pf-active');
342
+ setTimeout(() => {
343
+ notifyResize();
344
+ const canvas = p5Bridge._p?.canvas;
345
+ if (canvas) { canvas.setAttribute('tabindex', '0'); canvas.focus(); }
346
+ else appEl.focus();
347
+ }, 280);
348
+ }
349
+ function toggleDrawer() { drawerOpen ? closeDrawer() : openDrawer(); }
350
+
351
+ /* ── drag-to-resize handle, double-click-to-toggle ── */
352
+ let drag = null;
353
+ const DRAG_THRESHOLD = 5; /* px movement before it counts as a drag */
354
+ const SNAP_CLOSE_H = 120; /* px — snap shut when dragged below this */
355
+
356
+ /* Transparent overlay that captures all pointer events during drag,
357
+ preventing the ACE editor / canvas from swallowing mousemove/mouseup */
358
+ const dragOverlay = document.createElement('div');
359
+ Object.assign(dragOverlay.style, {
360
+ position: 'fixed', inset: '0',
361
+ zIndex: '9999', cursor: 'ns-resize',
362
+ display: 'none',
363
+ });
364
+ document.body.appendChild(dragOverlay);
365
+
366
+ function dragStart(e) {
367
+ if (e.target.closest('.pf-btn')) return;
368
+ if (e.target.closest('#pf-grip')) return; /* let grip handle its own click */
369
+ const clientY = e.touches ? e.touches[0].clientY : e.clientY;
370
+ drag = { y: clientY, h: drawerOpen ? drawerH : 0, moved: false };
371
+ dragOverlay.style.display = 'block';
372
+ document.body.style.userSelect = 'none';
373
+ if (e.cancelable) e.preventDefault();
374
+ e.stopPropagation();
375
+ }
376
+ function dragMove(e) {
377
+ if (!drag) return;
378
+ const clientY = e.touches ? e.touches[0].clientY : e.clientY;
379
+ const delta = drag.y - clientY; /* up = larger */
380
+ if (Math.abs(delta) > DRAG_THRESHOLD) drag.moved = true;
381
+ if (!drag.moved) return;
382
+
383
+ const newH = Math.max(0, Math.min(window.innerHeight - 50, drag.h + delta));
384
+
385
+ if (newH < SNAP_CLOSE_H) {
386
+ /* Show closing preview: collapse to handle only */
387
+ drawerEl.style.transition = 'none';
388
+ drawerEl.style.height = '32px';
389
+ } else {
390
+ drawerH = newH;
391
+ _applyDrawerH();
392
+ if (!drawerOpen) openDrawer();
393
+ drawerEl.style.transition = 'none';
394
+ drawerEl.style.height = drawerH + 'px';
395
+ }
396
+ notifyResize();
397
+ }
398
+ function dragEnd(e) {
399
+ if (!drag) return;
400
+ const wasDrag = drag.moved;
401
+ const clientY = (e.changedTouches ? e.changedTouches[0].clientY : e.clientY) ?? drag.y;
402
+ const delta = drag.y - clientY;
403
+ const endH = drag.h + delta;
404
+ drag = null;
405
+ dragOverlay.style.display = 'none';
406
+ document.body.style.userSelect = '';
407
+ drawerEl.style.transition = '';
408
+ drawerEl.style.height = ''; /* revert to CSS var */
409
+
410
+ if (wasDrag) {
411
+ if (endH < SNAP_CLOSE_H) {
412
+ closeDrawer();
413
+ } else {
414
+ drawerH = Math.max(SNAP_CLOSE_H, Math.min(window.innerHeight - 50, endH));
415
+ _applyDrawerH();
416
+ if (!drawerOpen) openDrawer();
417
+ }
418
+ notifyResize();
419
+ }
420
+ /* clicks are handled by the grip click listener */
421
+ }
422
+
423
+ /* Grip (hamburger) click → toggle drawer at current/default height */
424
+ gripEl.addEventListener('click', (e) => {
425
+ e.stopPropagation();
426
+ toggleDrawer();
427
+ });
428
+
429
+ /* Use capture on mousedown so we get it before p5's window listener */
430
+ handleEl.addEventListener('mousedown', dragStart, true);
431
+ document.addEventListener('mousemove', dragMove);
432
+ document.addEventListener('mouseup', dragEnd);
433
+ handleEl.addEventListener('touchstart', dragStart, { passive: false });
434
+ document.addEventListener('touchmove', dragMove, { passive: true });
435
+ document.addEventListener('touchend', dragEnd);
436
+
437
+ /* ── raw mouse tracking (bypasses p5's cached offset) ── */
438
+ let _rawMouseX = 0, _rawMouseY = 0;
439
+ window.addEventListener('mousemove', (e) => {
440
+ _rawMouseX = e.clientX;
441
+ _rawMouseY = e.clientY;
442
+ }, { passive: true });
443
+ window.addEventListener('touchmove', (e) => {
444
+ if (e.touches.length > 0) {
445
+ _rawMouseX = e.touches[0].clientX;
446
+ _rawMouseY = e.touches[0].clientY;
447
+ }
448
+ }, { passive: true });
449
+ /* Expose to Pyodide so _pf_refresh can read it */
450
+ window._pfMouse = () => {
451
+ const canvas = p5Bridge._p ? p5Bridge._p.canvas : null;
452
+ if (!canvas) return [0, 0];
453
+ const r = canvas.getBoundingClientRect();
454
+ /* Use logical dimensions (p5Bridge._w/_h), NOT canvas.width/height which
455
+ may be doubled by p5's pixelDensity on HiDPI screens */
456
+ const sx = p5Bridge._w / r.width;
457
+ const sy = p5Bridge._h / r.height;
458
+ return [
459
+ (_rawMouseX - r.left) * sx,
460
+ (_rawMouseY - r.top) * sy,
461
+ ];
462
+ };
463
+ function showError(txt) { errEl.textContent = txt; errEl.style.display = 'block'; openDrawer(); }
464
+ function clearError() { errEl.textContent = ''; errEl.style.display = 'none'; }
465
+
466
+ /* ─────────────────── CANVAS FIT ─────────────── */
467
+ function fitCanvas() {
468
+ if (!p5Bridge._p || p5Bridge._mode !== 'fit') return;
469
+ const w = p5Bridge._w, h = p5Bridge._h;
470
+ if (!w || !h) return;
471
+ const aw = appEl.clientWidth, ah = appEl.clientHeight;
472
+ const s = Math.min(aw / w, ah / h);
473
+ viewEl.style.transform = `scale(${s})`;
474
+ }
475
+
476
+ /* Call after any layout change (drawer resize, window resize).
477
+ Handles both fixed-size (CSS scale) and fullscreen (canvas resize) sketches,
478
+ and fires the user's windowResized() Python callback if defined. */
479
+ function notifyResize() {
480
+ if (p5Bridge._mode === 'fullscreen') {
481
+ p5Bridge.size('max'); /* resizes the canvas to new app area */
482
+ } else {
483
+ fitCanvas();
484
+ }
485
+ /* Trigger p5's windowResized handler (which in turn calls the user's callback) */
486
+ if (pInst && typeof pInst.windowResized === 'function') {
487
+ try { pInst.windowResized(); } catch (_) {}
488
+ }
489
+ if (aceInst) aceInst.resize();
490
+ }
491
+
492
+ window.addEventListener('resize', notifyResize);
493
+
494
+ /* ─────────────────── p5 BRIDGE ──────────────── */
495
+ let pInst = null;
496
+
497
+ /* Special-cased methods that need JS-side logic beyond a plain p5 forward */
498
+ const _p5BridgeCore = {
499
+ _p: null, _mode: 'fit', _w: 0, _h: 0,
500
+ _setP(p) { this._p = p; },
501
+
502
+ /* size() manages canvas creation, CSS scale, and fullscreen mode */
503
+ size(w, h, renderer) {
504
+ if (!this._p) return;
505
+ const r = renderer ?? undefined;
506
+ if (w === 'max' || w == null) {
507
+ this._mode = 'fullscreen';
508
+ this._w = appEl.clientWidth; this._h = appEl.clientHeight;
509
+ /* renderer change requires a fresh createCanvas, not resizeCanvas */
510
+ if (r !== undefined || !this._p.canvas)
511
+ this._p.createCanvas(this._w, this._h, r);
512
+ else
513
+ this._p.resizeCanvas(this._w, this._h);
514
+ viewEl.style.transform = 'scale(1)';
515
+ } else {
516
+ this._mode = 'fit';
517
+ this._w = Math.max(1, w | 0); this._h = Math.max(1, h | 0);
518
+ if (r !== undefined || !this._p.canvas)
519
+ this._p.createCanvas(this._w, this._h, r);
520
+ else
521
+ this._p.resizeCanvas(this._w, this._h);
522
+ fitCanvas();
523
+ }
524
+ },
525
+
526
+ /* smooth/noSmooth also toggle CSS image-rendering on the canvas */
527
+ noSmooth() {
528
+ this._p?.noSmooth();
529
+ if (this._p?.canvas) this._p.canvas.style.imageRendering = 'pixelated';
530
+ },
531
+ smooth() {
532
+ this._p?.smooth();
533
+ if (this._p?.canvas) this._p.canvas.style.imageRendering = 'auto';
534
+ },
535
+
536
+ /* pyfrilet-specific: write into the handle bar hint */
537
+ sketchTitle(s) { hintEl.textContent = String(s); },
538
+ };
539
+
540
+ /* Proxy: any property not in _p5BridgeCore is forwarded to the live p5 instance.
541
+ This gives Python access to the full p5 API without listing every function. */
542
+ const p5Bridge = new Proxy(_p5BridgeCore, {
543
+ get(target, prop) {
544
+ if (prop in target) return typeof target[prop] === 'function'
545
+ ? target[prop].bind(target)
546
+ : target[prop];
547
+ /* Forward to live p5 instance */
548
+ if (target._p && prop in target._p) {
549
+ const v = target._p[prop];
550
+ return typeof v === 'function' ? v.bind(target._p) : v;
551
+ }
552
+ return undefined;
553
+ },
554
+ set(target, prop, value) {
555
+ /* Internal state (_p, _mode, _w, _h) stays on target */
556
+ if (prop.startsWith('_')) { target[prop] = value; return true; }
557
+ if (target._p) target._p[prop] = value;
558
+ return true;
559
+ },
560
+ });
561
+ window.p5py = p5Bridge;
562
+
563
+ function stopSketch() {
564
+ if (pInst) { try { pInst.remove(); } catch (e) {} pInst = null; }
565
+ sketchEl.innerHTML = '';
566
+ p5Bridge._p = null; p5Bridge._mode = 'fit'; p5Bridge._w = 0; p5Bridge._h = 0;
567
+ viewEl.style.transform = 'scale(1)';
568
+ hintEl.textContent = 'Shift+Entrée → relancer \u00a0·\u00a0 Échap → ouvrir/fermer';
569
+ if (setupProxy) { setupProxy.destroy(); setupProxy = null; }
570
+ if (drawProxy) { drawProxy.destroy(); drawProxy = null; }
571
+ if (mousePressedProxy) { mousePressedProxy.destroy(); mousePressedProxy = null; }
572
+ if (keyPressedProxy) { keyPressedProxy.destroy(); keyPressedProxy = null; }
573
+ }
574
+
575
+ /* ─────────────────── ACE EDITOR ─────────────── */
576
+ let aceInst = null;
577
+
578
+ function initAce() {
579
+ /* In local mode ACE cannot auto-detect where its dynamic modules live
580
+ (searchbox, keybindings…), so we set basePath explicitly. */
581
+ if (URLS.ace.startsWith('vendor') || !URLS.ace.startsWith('http')) {
582
+ ace.config.set('basePath', URLS.ace.replace(/\/[^/]+$/, '/'));
583
+ }
584
+ aceInst = ace.edit('pf-ace');
585
+ aceInst.session.setMode('ace/mode/python');
586
+ aceInst.setTheme('ace/theme/monokai');
587
+ aceInst.setValue(initialCode, -1);
588
+ aceInst.setOptions({
589
+ fontSize : '15px',
590
+ showPrintMargin: false,
591
+ wrap : false,
592
+ useWorker : false,
593
+ tabSize : 4,
594
+ enableBasicAutocompletion: true,
595
+ enableLiveAutocompletion : true,
596
+ enableSnippets : true,
597
+ });
598
+
599
+ aceInst.commands.addCommand({
600
+ name: 'pfRun',
601
+ bindKey: { win: 'Shift-Enter', mac: 'Shift-Enter' },
602
+ exec: () => { runCode(); },
603
+ });
604
+ aceInst.commands.addCommand({
605
+ name: 'pfClose',
606
+ bindKey: { win: 'Escape', mac: 'Escape' },
607
+ exec: closeDrawer,
608
+ });
609
+ aceInst.commands.addCommand({
610
+ name: 'pfSave',
611
+ bindKey: { win: 'Ctrl-S', mac: 'Command-S' },
612
+ exec: saveCode,
613
+ });
614
+ aceInst.commands.addCommand({
615
+ name: 'pfReset',
616
+ bindKey: { win: 'Ctrl-R', mac: 'Command-R' },
617
+ exec: () => {
618
+ if (confirm('Réinitialiser le code ? Les modifications seront perdues.')) {
619
+ aceInst.setValue(starterCode, -1);
620
+ runCode();
621
+ }
622
+ },
623
+ });
624
+
625
+ let saveTimer = null;
626
+ aceInst.session.on('change', () => {
627
+ clearTimeout(saveTimer);
628
+ saveTimer = setTimeout(saveCode, 350);
629
+ });
630
+ }
631
+
632
+ function saveCode() {
633
+ try { localStorage.setItem(SK, aceInst ? aceInst.getValue() : initialCode); } catch (e) {}
634
+ }
635
+ window.addEventListener('beforeunload', saveCode);
636
+
637
+ /* ─────────────────── PYODIDE ────────────────── */
638
+ let pyodide = null, pyodideReady = null;
639
+
640
+ async function ensurePyodide() {
641
+ if (pyodideReady) return pyodideReady;
642
+ pyodideReady = (async () => {
643
+ const opts = {};
644
+ if (URLS.pyodideIndex) opts.indexURL = URLS.pyodideIndex;
645
+ pyodide = await loadPyodide(opts);
646
+
647
+ /* Build the "p5" Python module via dynamic introspection of a dummy instance */
648
+ pyodide.runPython(`
649
+ import sys, types, js
650
+ from js import p5py, _pfMouse
651
+ from pyodide.ffi import JsProxy
652
+
653
+ # ── Python builtins that must NOT be shadowed ──────────────────────
654
+ _BLACKLIST = frozenset({
655
+ 'abs','all','any','bin','bool','bytes','callable','chr','compile',
656
+ 'delattr','dict','dir','divmod','enumerate','eval','exec',
657
+ 'filter','float','format','frozenset','getattr','globals','hasattr',
658
+ 'hash','help','hex','id','input','int','isinstance','issubclass',
659
+ 'iter','len','list','locals','map','max','min','next','object',
660
+ 'oct','open','ord','pow','print','property','range','repr',
661
+ 'reversed','round','set','setattr','slice','sorted','staticmethod',
662
+ 'str','sum','super','tuple','type','vars','zip',
663
+ # p5 lifecycle hooks — user defines these, we don't import them
664
+ 'setup','draw','preload',
665
+ })
666
+
667
+ # ── Introspect a hidden dummy p5 instance ─────────────────────────
668
+ _dummy_node = js.document.createElement('div')
669
+ _dummy = js.p5.new(lambda _: None, _dummy_node)
670
+
671
+ _p5_functions = set() # names of callable JS members
672
+ _p5_attributes = set() # names of scalar/readable members
673
+
674
+ for _n in dir(_dummy):
675
+ if _n.startswith('_') or _n in _BLACKLIST:
676
+ continue
677
+ _v = getattr(_dummy, _n)
678
+ if isinstance(_v, JsProxy):
679
+ if callable(_v):
680
+ _p5_functions.add(_n)
681
+ # non-callable JsProxy (canvas, pixels…) → skip
682
+ else:
683
+ _p5_attributes.add(_n)
684
+
685
+ # Read real initial values now, while dummy is still alive
686
+ _attr_init = {}
687
+ for _n in _p5_attributes:
688
+ try:
689
+ _attr_init[_n] = getattr(_dummy, _n)
690
+ except Exception:
691
+ _attr_init[_n] = 0
692
+
693
+ _dummy.remove()
694
+ del _dummy, _dummy_node
695
+
696
+ # ── Build module ───────────────────────────────────────────────────
697
+ m = types.ModuleType("p5")
698
+
699
+ # Generic function wrapper: delegates to live p5Bridge instance
700
+ class _FW:
701
+ __slots__ = ('_n',)
702
+ def __init__(self, n): self._n = n
703
+ def __call__(self, *a): return getattr(p5py, self._n)(*a)
704
+ def __repr__(self): return f'<p5 function {self._n}>'
705
+
706
+ for _n in _p5_functions:
707
+ setattr(m, _n, _FW(_n))
708
+
709
+ # ── Special overrides (our bridge has custom behaviour) ────────────
710
+ # smooth/noSmooth exist on a real p5 instance so introspection finds
711
+ # them — but our Proxy overrides them to also toggle CSS image-rendering.
712
+ # size and sketchTitle are pyfrilet-only: NOT on a real p5 instance,
713
+ # so introspection misses them — add them explicitly.
714
+ for _n in ('size', 'sketchTitle'):
715
+ setattr(m, _n, _FW(_n))
716
+ _p5_functions.add(_n) # keep __all__ consistent
717
+
718
+ # mouseX / mouseY: override with our accurate coordinate calculator
719
+ # (p5's own values are wrong when a CSS-transformed parent is used)
720
+ _MOUSE_OVERRIDE = frozenset({'mouseX', 'mouseY'})
721
+
722
+ # Initial values from the dummy instance — constants like WEBGL, DEGREES,
723
+ # LEFT_ARROW… are correct from the very first setup() call.
724
+ for _n in _p5_attributes:
725
+ if _n in _MOUSE_OVERRIDE:
726
+ setattr(m, _n, 0.0)
727
+ else:
728
+ setattr(m, _n, _attr_init.get(_n, 0))
729
+
730
+ # Build __all__ for import * (after all explicit additions)
731
+ m.__all__ = sorted(_p5_functions | _p5_attributes)
732
+
733
+ # ── _pf_refresh: called before every event callback ───────────────
734
+ def _pf_refresh(ns):
735
+ # accurate mouse coords (bypasses p5's stale CSS-transform offset)
736
+ mx, my = _pfMouse()
737
+
738
+ # update all known scalar attributes from live instance
739
+ for _k in _p5_attributes:
740
+ if _k in _MOUSE_OVERRIDE:
741
+ _v = mx if _k == 'mouseX' else my
742
+ else:
743
+ try:
744
+ _v = getattr(p5py, _k)
745
+ except Exception:
746
+ continue
747
+ setattr(m, _k, _v)
748
+ if _k in ns:
749
+ ns[_k] = _v
750
+
751
+ sys.modules["p5"] = m
752
+ `);
753
+
754
+ /* Inject p5 symbols into ACE autocomplete */
755
+ if (aceInst) {
756
+ const p5all = pyodide.runPython('list(m.__all__)').toJs();
757
+ _registerP5Completer(p5all);
758
+ }
759
+ })();
760
+ return pyodideReady;
761
+ }
762
+
763
+ /* Build and register a custom ACE completer for p5 symbols.
764
+ Called once after Pyodide has built the module. */
765
+ function _registerP5Completer(symbols) {
766
+ const completions = symbols.map(name => ({
767
+ caption: name,
768
+ value: name,
769
+ meta: 'p5',
770
+ score: 1000, /* show above generic word completions */
771
+ }));
772
+ const completer = {
773
+ getCompletions(editor, session, pos, prefix, callback) {
774
+ callback(null, prefix.length > 0 ? completions : []);
775
+ },
776
+ };
777
+ /* ACE stores completers on the language tools module */
778
+ const lt = ace.require('ace/ext/language_tools');
779
+ if (lt && Array.isArray(lt.completers)) {
780
+ /* Remove any previous p5 completer before re-adding */
781
+ lt.completers = lt.completers.filter(c => c._pyfrilet !== true);
782
+ }
783
+ completer._pyfrilet = true;
784
+ aceInst.completers = [
785
+ ...(aceInst.completers || []),
786
+ completer,
787
+ ];
788
+ }
789
+
790
+ /* ─────────────────── RUN CODE ───────────────── */
791
+ let running = false;
792
+ let setupProxy = null, drawProxy = null, mousePressedProxy = null, keyPressedProxy = null;
793
+
794
+ async function runCode() {
795
+ if (running) return;
796
+ running = true;
797
+ btnRun.classList.add('pf-running');
798
+ clearError();
799
+ stopSketch();
800
+
801
+ if (!pyodide) {
802
+ loaderMsg.textContent = 'Initialisation de Pyodide…';
803
+ loaderEl.style.display = 'flex';
804
+ }
805
+
806
+ try {
807
+ await ensurePyodide();
808
+ } catch (e) {
809
+ loaderEl.style.display = 'none';
810
+ showError('Erreur Pyodide : ' + e);
811
+ running = false; btnRun.classList.remove('pf-running'); return;
812
+ }
813
+
814
+ loaderEl.style.display = 'none';
815
+
816
+ const code = aceInst ? aceInst.getValue() : initialCode;
817
+ pyodide.globals.set('_USER_CODE', code);
818
+
819
+ try {
820
+ pyodide.runPython('_ns = {}; exec(_USER_CODE, _ns, _ns)');
821
+ } catch (e) {
822
+ showError(String(e));
823
+ running = false; btnRun.classList.remove('pf-running'); return;
824
+ }
825
+
826
+ let pySetup, pyDraw, pyMP, pyKP;
827
+ try {
828
+ pySetup = pyodide.runPython("_ns.get('setup')");
829
+ pyDraw = pyodide.runPython("_ns.get('draw')");
830
+ pyMP = pyodide.runPython("_ns.get('mousePressed')");
831
+ pyKP = pyodide.runPython("_ns.get('keyPressed')");
832
+ } catch (e) {
833
+ showError(String(e));
834
+ running = false; btnRun.classList.remove('pf-running'); return;
835
+ }
836
+
837
+ if (!pyDraw) {
838
+ showError('Le script doit définir au moins une fonction draw().');
839
+ running = false; btnRun.classList.remove('pf-running'); return;
840
+ }
841
+
842
+ const { create_proxy } = pyodide.pyimport('pyodide.ffi');
843
+ const pyWR = pyodide.runPython("_ns.get('windowResized')");
844
+ const pyRefresh = pyodide.globals.get('_pf_refresh');
845
+ const pyNs = pyodide.globals.get('_ns');
846
+
847
+ setupProxy = pySetup ? create_proxy(() => { try { pySetup(); } catch (e) { showError(String(e)); } }) : null;
848
+ drawProxy = create_proxy(() => {
849
+ try { pyRefresh(pyNs); pyDraw(); } catch (e) { showError(String(e)); stopSketch(); }
850
+ });
851
+ mousePressedProxy = pyMP ? create_proxy(() => {
852
+ try { pyRefresh(pyNs); pyMP(); } catch (e) { showError(String(e)); }
853
+ }) : null;
854
+ keyPressedProxy = pyKP ? create_proxy(() => {
855
+ try { pyRefresh(pyNs); pyKP(); } catch (e) { showError(String(e)); }
856
+ }) : null;
857
+ const windowResizedProxy = pyWR ? create_proxy(() => { try { pyWR(); } catch (e) { showError(String(e)); } }) : null;
858
+
859
+ let setupDone = false;
860
+ pInst = new p5((p) => {
861
+ p5Bridge._setP(p);
862
+ p.setup = () => {
863
+ /* Let the user's setup() call size() to create the canvas — this
864
+ allows WEBGL mode and custom dimensions to work correctly.
865
+ Only create a default 2D canvas if the user didn't call size(). */
866
+ if (setupProxy) setupProxy();
867
+ if (!p.canvas) {
868
+ /* No size() called — fall back to a default 200×200 canvas */
869
+ p5Bridge.size(200, 200);
870
+ }
871
+ if (typeof p._updateMouseCoords === 'function') {
872
+ p._updateMouseCoords({ clientX: 0, clientY: 0 });
873
+ }
874
+ p.windowResized();
875
+ setupDone = true;
876
+ };
877
+ p.draw = () => { if (setupDone) drawProxy(); };
878
+ p.mousePressed = () => { if (setupDone && mousePressedProxy) mousePressedProxy(); };
879
+ p.keyPressed = () => { if (setupDone && keyPressedProxy) keyPressedProxy(); };
880
+ /* Called by p5 on actual window resize AND by notifyResize() */
881
+ p.windowResized = () => {
882
+ if (p5Bridge._mode === 'fullscreen') p5Bridge.size('max');
883
+ else fitCanvas();
884
+ if (windowResizedProxy) windowResizedProxy();
885
+ };
886
+ }, sketchEl);
887
+
888
+ running = false;
889
+ btnRun.classList.remove('pf-running');
890
+ }
891
+
892
+ /* ─────────────────── DOWNLOAD ───────────────── */
893
+ async function download() {
894
+ btnDl.style.opacity = '.4';
895
+ btnDl.style.pointerEvents = 'none';
896
+
897
+ let pfSrc = '';
898
+
899
+ /* 1. try fetching from original <script src="..."> */
900
+ if (_scriptSrc) {
901
+ try { pfSrc = await (await fetch(_scriptSrc)).text(); } catch (_) {}
902
+ }
903
+
904
+ /* 2. fallback: look for inline <script data-pyfrilet> (present in self-downloaded files) */
905
+ if (!pfSrc) {
906
+ const inlineEl = document.querySelector('script[data-pyfrilet]');
907
+ if (inlineEl) pfSrc = inlineEl.textContent;
908
+ }
909
+
910
+ const code = aceInst ? aceInst.getValue() : initialCode;
911
+ const html = buildStandaloneHTML(code, pfSrc);
912
+ const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
913
+ const url = URL.createObjectURL(blob);
914
+ const a = Object.assign(document.createElement('a'), { href: url, download: 'sketch.html' });
915
+ document.body.appendChild(a);
916
+ a.click();
917
+ document.body.removeChild(a);
918
+ URL.revokeObjectURL(url);
919
+
920
+ btnDl.style.opacity = '';
921
+ btnDl.style.pointerEvents = '';
922
+ }
923
+
924
+ const STANDALONE_TEMPLATE = `<!doctype html>
925
+ <html lang="fr">
926
+ <head>
927
+ <meta charset="utf-8">
928
+ <meta name="viewport" content="width=device-width, initial-scale=1">
929
+ <title>export</title>
930
+ <script src="FILLME-JS"><\/script>
931
+ </head>
932
+ <body>
933
+
934
+ <script type="text/python" data-sources="cdn">
935
+ FILLME-PYTHON
936
+ <\/script>
937
+
938
+ </body>
939
+ </html>`;
940
+
941
+ function buildStandaloneHTML(code, pfSrc) {
942
+ const b64 = pfSrc
943
+ ? 'data:text/javascript;base64,' + btoa(unescape(encodeURIComponent(pfSrc)))
944
+ : '/* pyfrilet source unavailable */';
945
+ return STANDALONE_TEMPLATE
946
+ .replace('FILLME-JS', b64)
947
+ .replace('FILLME-PYTHON', code);
948
+ }
949
+
950
+ /* ─────────────────── BUTTON HANDLERS ────────── */
951
+ btnRun.addEventListener('click', () => runCode());
952
+
953
+ /* Code button: open at full screen height, or close if already open */
954
+ btnCode.addEventListener('click', () => {
955
+ if (drawerOpen) {
956
+ closeDrawer();
957
+ } else {
958
+ drawerH = window.innerHeight - 32;
959
+ _applyDrawerH();
960
+ openDrawer();
961
+ }
962
+ });
963
+
964
+ btnDl.addEventListener('click', download);
965
+ const HELP_URL = 'https://codeberg.org/nopid/pyfrilet';
966
+ btnHelp.addEventListener('click', () => window.open(HELP_URL, '_blank'));
967
+
968
+ btnReset.addEventListener('click', () => {
969
+ if (aceInst && confirm('Réinitialiser le code ? Les modifications seront perdues.')) {
970
+ aceInst.setValue(starterCode, -1);
971
+ runCode();
972
+ }
973
+ });
974
+
975
+ /* ─────────────────── GLOBAL KEYBOARD (capture phase = before p5 & ACE) ── */
976
+ window.addEventListener('keydown', (ev) => {
977
+ /* Detect whether ACE currently has keyboard focus */
978
+ const aceHasFocus = drawerOpen && aceInst &&
979
+ aceInst.isFocused && aceInst.isFocused();
980
+
981
+ /* Arrow keys: prevent browser scroll so p5 receives them intact.
982
+ Skip when ACE has focus so it can move the cursor normally. */
983
+ if (!aceHasFocus && ['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(ev.key)) {
984
+ ev.preventDefault();
985
+ return;
986
+ }
987
+ /* Shift+Enter: run — always intercept, even inside ACE */
988
+ if (ev.key === 'Enter' && ev.shiftKey) {
989
+ ev.preventDefault();
990
+ runCode();
991
+ return;
992
+ }
993
+ /* Escape: when ACE has focus, only close the drawer — let the event
994
+ propagate first so ACE can dismiss its own overlays (search, autocomplete).
995
+ When ACE does NOT have focus, toggle the drawer immediately. */
996
+ if (ev.key === 'Escape') {
997
+ if (aceHasFocus) {
998
+ /* Let ACE handle it first (close search bar etc.),
999
+ then close the drawer on the next tick if still open */
1000
+ setTimeout(() => { if (drawerOpen) closeDrawer(); }, 0);
1001
+ return; /* do NOT preventDefault/stopPropagation */
1002
+ }
1003
+ ev.preventDefault();
1004
+ ev.stopPropagation();
1005
+ drawerOpen ? closeDrawer() : openDrawer();
1006
+ return;
1007
+ }
1008
+ /* When ACE has focus, let it handle all other shortcuts (Cmd+F, Cmd+Z…) */
1009
+ if (aceHasFocus) return;
1010
+ /* Ctrl/Cmd+S: save */
1011
+ if ((ev.key === 's' || ev.key === 'S') && (ev.ctrlKey || ev.metaKey)) {
1012
+ ev.preventDefault();
1013
+ saveCode();
1014
+ return;
1015
+ }
1016
+ /* Ctrl/Cmd+R: reset code (prevent browser reload) */
1017
+ if ((ev.key === 'r' || ev.key === 'R') && (ev.ctrlKey || ev.metaKey) && !ev.altKey) {
1018
+ ev.preventDefault();
1019
+ if (aceInst && confirm('Réinitialiser le code ? Les modifications seront perdues.')) {
1020
+ aceInst.setValue(starterCode, -1);
1021
+ runCode();
1022
+ }
1023
+ return;
1024
+ }
1025
+ }, true /* capture phase */);
1026
+
1027
+ /* ─────────────────── SCRIPT LOADING ─────────── */
1028
+ function loadScript(src) {
1029
+ return new Promise((resolve, reject) => {
1030
+ const s = document.createElement('script');
1031
+ s.src = src;
1032
+ s.onload = resolve;
1033
+ s.onerror = () => reject(new Error('Impossible de charger : ' + src));
1034
+ document.head.appendChild(s);
1035
+ });
1036
+ }
1037
+
1038
+ (async () => {
1039
+ loaderMsg.textContent = 'Chargement des dépendances…';
1040
+ loaderEl.style.display = 'flex';
1041
+
1042
+ try {
1043
+ await loadScript(URLS.p5);
1044
+ await loadScript(URLS.ace);
1045
+ await loadScript(URLS.acePython);
1046
+ await loadScript(URLS.aceMonokai);
1047
+ await loadScript(URLS.aceLangTools);
1048
+ await loadScript(URLS.aceSearchbox);
1049
+ await loadScript(URLS.pyodide);
1050
+ } catch (e) {
1051
+ loaderMsg.textContent = '⚠ ' + e.message;
1052
+ document.getElementById('pf-loader-bar').style.display = 'none';
1053
+ return;
1054
+ }
1055
+
1056
+ initAce();
1057
+ await runCode();
1058
+ loaderEl.style.display = 'none';
1059
+ })();
1060
+ }
1061
+
1062
+ })();