Skip to content

Commit 667f48c

Browse files
committed
Merge remote-tracking branch 'upstream/main'
2 parents 32bd83b + e11a30a commit 667f48c

7 files changed

Lines changed: 261 additions & 19 deletions

File tree

web-app/index.html

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
<meta content="Interactive games, math visualizations, and utilities in the browser." name="twitter:description" />
3030
<title>Python Mini Projects — Interactive Web Edition</title>
3131
<link href="assets/favicon.svg" rel="icon" type="image/svg+xml" />
32+
<link rel="manifest" href="manifest.json" />
3233
<link href="css/styles.css" rel="stylesheet" />
3334
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
3435
integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw=="
@@ -381,7 +382,8 @@ <h2 class="playground-title">🐍 Python Playground</h2>
381382
<a href="https://pyodide.org" rel="noopener noreferrer" target="_blank">Pyodide</a>. No install, no backend.
382383
</p>
383384
<p class="playground-hint">
384-
<kbd>Ctrl</kbd>+<kbd>Enter</kbd> runs code &nbsp;·&nbsp;
385+
<kbd>Ctrl</kbd>+<kbd>Enter</kbd> / <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd> runs code &nbsp;·&nbsp;
386+
<kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>L</kbd> clears console &nbsp;·&nbsp;
385387
<kbd>Tab</kbd> inserts 4 spaces
386388
</p>
387389
</div>
@@ -1491,6 +1493,15 @@ <h3 id="confirmModalTitle">Confirm</h3>
14911493
</div>
14921494
</div>
14931495

1496+
<script>
1497+
if ('serviceWorker' in navigator) {
1498+
window.addEventListener('load', () => {
1499+
navigator.serviceWorker.register('./service-worker.js')
1500+
.then((reg) => console.log('Service Worker registered:', reg.scope))
1501+
.catch((err) => console.error('Service Worker registration failed:', err));
1502+
});
1503+
}
1504+
</script>
14941505
</body>
14951506

14961507
</html>

web-app/js/playground.js

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,7 @@
480480
...CM.completionKeymap,
481481
/* Search */
482482
...CM.searchKeymap,
483-
/* Ctrl/Cmd + Enter → Run Code */
483+
/* Ctrl/Cmd + Enter or Ctrl/Cmd + Shift + Enter → Run Code */
484484
{
485485
key: "Ctrl-Enter",
486486
mac: "Cmd-Enter",
@@ -489,6 +489,23 @@
489489
return true;
490490
},
491491
},
492+
{
493+
key: "Ctrl-Shift-Enter",
494+
mac: "Cmd-Shift-Enter",
495+
run: function () {
496+
runCode();
497+
return true;
498+
},
499+
},
500+
/* Ctrl/Cmd + Shift + L → Clear Console */
501+
{
502+
key: "Ctrl-Shift-l",
503+
mac: "Cmd-Shift-l",
504+
run: function () {
505+
resetConsole();
506+
return true;
507+
},
508+
},
492509
]),
493510

494511
/* Update listener — keeps `editor.value` semantic alive */
@@ -1004,24 +1021,56 @@
10041021
});
10051022
}
10061023

1007-
// Keyboard shortcut for copy: Ctrl+Shift+C in editor
1008-
if (cmView) {
1009-
document.addEventListener("keydown", function (event) {
1010-
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.code === "KeyC") {
1011-
// Check if focus is in the editor
1012-
if (
1013-
editorMount &&
1014-
(editorMount.contains(document.activeElement) ||
1015-
document.activeElement === editorMount)
1016-
) {
1017-
event.preventDefault();
1018-
if (copyEditorCodeBtn) {
1019-
copyEditorCodeBtn.click();
1020-
}
1024+
/* ──────────────────────────────────────────────────────────────
1025+
Keyboard Shortcuts — Issue #1716 & Issue #1215
1026+
- Ctrl/Cmd + Enter: Run Python Code
1027+
- Ctrl/Cmd + Shift + L: Clear Output Console
1028+
- Ctrl/Cmd + Shift + C: Copy Code (when focused in editor)
1029+
────────────────────────────────────────────────────────────── */
1030+
document.addEventListener("keydown", function (event) {
1031+
if (event.defaultPrevented) return;
1032+
1033+
// Only process if playground section is active/visible
1034+
if (!playgroundSection || playgroundSection.style.display === "none") return;
1035+
1036+
var isMod = event.ctrlKey || event.metaKey;
1037+
if (!isMod) return;
1038+
1039+
// Ctrl/Cmd + Shift + C: Copy editor code
1040+
if (
1041+
event.shiftKey &&
1042+
(event.key === "c" || event.key === "C" || event.code === "KeyC")
1043+
) {
1044+
if (
1045+
editorMount &&
1046+
(editorMount.contains(document.activeElement) ||
1047+
document.activeElement === editorMount)
1048+
) {
1049+
event.preventDefault();
1050+
if (copyEditorCodeBtn) {
1051+
copyEditorCodeBtn.click();
10211052
}
10221053
}
1023-
});
1024-
}
1054+
return;
1055+
}
1056+
1057+
// Ctrl/Cmd + Enter: Run Code
1058+
if (event.key === "Enter" || event.code === "Enter") {
1059+
event.preventDefault();
1060+
runCode();
1061+
return;
1062+
}
1063+
1064+
// Ctrl/Cmd + Shift + L: Clear Output Console
1065+
if (
1066+
event.shiftKey &&
1067+
(event.key === "l" || event.key === "L" || event.code === "KeyL")
1068+
) {
1069+
event.preventDefault();
1070+
resetConsole();
1071+
return;
1072+
}
1073+
});
10251074
if (saveDraftBtn) {
10261075
saveDraftBtn.addEventListener("click", function () {
10271076
var draftName = prompt("Enter a name for this draft:");

web-app/js/projects.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ function getProjectHTML(projectName) {
3838
"tsp-visualizer": getTspVisualizerHTML(),
3939
'minesweeper': getMinesweeperHTML()
4040
};
41+
return projects[projectName];
42+
}
43+
4144

4245

4346
function toPascalCase(str) {
@@ -660,4 +663,4 @@ function initNQueens() {
660663
solutionCountEl.textContent = "0";
661664
});
662665
}
663-
}
666+

web-app/manifest.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "Python Mini Projects",
3+
"short_name": "PythonProjects",
4+
"description": "Play beginner-friendly Python mini projects in your browser offline.",
5+
"start_url": "./index.html",
6+
"display": "standalone",
7+
"background_color": "#0c0f1a",
8+
"theme_color": "#0c0f1a",
9+
"orientation": "any",
10+
"icons": [
11+
{
12+
"src": "assets/favicon.svg",
13+
"sizes": "any",
14+
"type": "image/svg+xml",
15+
"purpose": "any maskable"
16+
}
17+
]
18+
}

web-app/service-worker.js

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
const CACHE_NAME = 'python-mini-projects-v1';
2+
3+
const PRECACHE_ASSETS = [
4+
'./',
5+
'./index.html',
6+
'./games.html',
7+
'./math.html',
8+
'./utilities.html',
9+
'./faq.html',
10+
'./privacy-policy.html',
11+
'./terms-condition.html',
12+
'./404.html',
13+
'./manifest.json',
14+
'./css/styles.css',
15+
'./assets/favicon.svg',
16+
'./assets/games-bg.webp',
17+
'./assets/math-bg.webp',
18+
'./assets/utilities-bg.webp',
19+
'./js/main.js',
20+
'./js/projects.js',
21+
'./js/playground.js',
22+
'./js/playground-worker.js',
23+
'./js/cm-editor.js',
24+
'./js/audio.js',
25+
'./js/audioManager.js',
26+
'./projects_registry.json'
27+
];
28+
29+
const PYODIDE_CDN_PREFIX = 'https://cdn.jsdelivr.net/pyodide/v0.26.2/full/';
30+
const PYODIDE_ASSETS = [
31+
PYODIDE_CDN_PREFIX + 'pyodide.js',
32+
PYODIDE_CDN_PREFIX + 'pyodide.asm.js',
33+
PYODIDE_CDN_PREFIX + 'pyodide.asm.wasm',
34+
PYODIDE_CDN_PREFIX + 'python_stdlib.zip',
35+
PYODIDE_CDN_PREFIX + 'pyodide-lock.json'
36+
];
37+
38+
// Install Event - Pre-cache core app shell & Pyodide assets
39+
self.addEventListener('install', (event) => {
40+
event.waitUntil(
41+
caches.open(CACHE_NAME).then(async (cache) => {
42+
await cache.addAll(PRECACHE_ASSETS);
43+
try {
44+
await cache.addAll(PYODIDE_ASSETS);
45+
} catch (err) {
46+
console.warn('Pyodide caching skipped or partial during SW install:', err);
47+
}
48+
}).then(() => self.skipWaiting())
49+
);
50+
});
51+
52+
// Activate Event - Clean up old caches
53+
self.addEventListener('activate', (event) => {
54+
event.waitUntil(
55+
caches.keys().then((cacheNames) => {
56+
return Promise.all(
57+
cacheNames
58+
.filter((name) => name !== CACHE_NAME)
59+
.map((name) => caches.delete(name))
60+
);
61+
}).then(() => self.clients.claim())
62+
);
63+
});
64+
65+
// Fetch Event - Serve from Cache, fallback to Network (and cache dynamically)
66+
self.addEventListener('fetch', (event) => {
67+
if (event.request.method !== 'GET') return;
68+
69+
const url = new URL(event.request.url);
70+
71+
// Network-first strategy for navigation requests (HTML pages)
72+
if (event.request.mode === 'navigate') {
73+
event.respondWith(
74+
fetch(event.request)
75+
.then((networkResponse) => {
76+
if (networkResponse && networkResponse.status === 200) {
77+
const responseClone = networkResponse.clone();
78+
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone));
79+
}
80+
return networkResponse;
81+
})
82+
.catch(() => {
83+
return caches.match(event.request).then((cachedResponse) => {
84+
return cachedResponse || caches.match('./index.html');
85+
});
86+
})
87+
);
88+
return;
89+
}
90+
91+
// Cache-first strategy for static resources & Pyodide CDN assets
92+
event.respondWith(
93+
caches.match(event.request).then((cachedResponse) => {
94+
if (cachedResponse) {
95+
return cachedResponse;
96+
}
97+
return fetch(event.request).then((networkResponse) => {
98+
if (
99+
networkResponse &&
100+
networkResponse.status === 200 &&
101+
(url.origin === self.location.origin ||
102+
url.hostname === 'cdn.jsdelivr.net' ||
103+
url.hostname === 'cdnjs.cloudflare.com' ||
104+
url.hostname === 'fonts.googleapis.com' ||
105+
url.hostname === 'fonts.gstatic.com')
106+
) {
107+
const responseClone = networkResponse.clone();
108+
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone));
109+
}
110+
return networkResponse;
111+
});
112+
})
113+
);
114+
});

web-app/tests-e2e/playground.spec.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,28 @@ test.describe('Python Playground', () => {
2929
const consoleOutput = page.locator('#consoleOutput');
3030
await expect(consoleOutput).toContainText('Hello, World!');
3131
});
32+
33+
test('should trigger run code via Ctrl+Enter and clear console via Ctrl+Shift+L', async ({ page }) => {
34+
await page.goto('/');
35+
36+
const playgroundTab = page.locator('button[data-sticky-category="playground"]');
37+
await expect(playgroundTab).toBeVisible();
38+
await playgroundTab.click();
39+
40+
const statusText = page.locator('#statusText');
41+
await expect(statusText).toHaveText(/Pyodide Ready/, { timeout: 30000 });
42+
43+
const consoleOutput = page.locator('#consoleOutput');
44+
45+
// Focus editor and press Control+Enter
46+
const editor = page.locator('.cm-content');
47+
await editor.focus();
48+
await page.keyboard.press('Control+Enter');
49+
50+
await expect(consoleOutput).toContainText('Hello, World!');
51+
52+
// Press Control+Shift+L to clear console
53+
await page.keyboard.press('Control+Shift+L');
54+
await expect(consoleOutput).toContainText('Console output will appear here');
55+
});
3256
});

web-app/tests-e2e/pwa.spec.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const { test, expect } = require('@playwright/test');
2+
3+
test.describe('PWA Capability and Offline Support', () => {
4+
test('should reference manifest.json and register service worker', async ({ page }) => {
5+
await page.goto('/');
6+
7+
// Check manifest link exists in head
8+
const manifestLink = page.locator('link[rel="manifest"]');
9+
await expect(manifestLink).toHaveAttribute('href', 'manifest.json');
10+
11+
// Fetch manifest file to verify its structure
12+
const response = await page.goto('/manifest.json');
13+
expect(response.status()).toBe(200);
14+
const manifest = await response.json();
15+
expect(manifest.name).toBe('Python Mini Projects');
16+
expect(manifest.start_url).toBe('./index.html');
17+
expect(manifest.display).toBe('standalone');
18+
19+
// Fetch service-worker.js to verify it exists
20+
const swResponse = await page.goto('/service-worker.js');
21+
expect(swResponse.status()).toBe(200);
22+
});
23+
});

0 commit comments

Comments
 (0)