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/LICENSE +501 -0
- package/README.md +320 -0
- package/package.json +15 -0
- package/pyfrilet.js +1062 -0
- package/pyfrilet.min.js +1 -0
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 · 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)">▶</button>
|
|
229
|
+
<button class="pf-btn" id="pf-btn-code" title="Éditeur plein écran">✏️</button>
|
|
230
|
+
<button class="pf-btn" id="pf-btn-dl" title="Télécharger HTML autonome">💾</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)">↻</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
|
+
})();
|