From d29c2f2e3ea71ec5f88b139493503ca0d782de0e Mon Sep 17 00:00:00 2001 From: a2nr Date: Tue, 31 Mar 2026 12:08:42 +0700 Subject: [PATCH] feat: Integrate CircuitJS1 into Elemes LMS - Added CircuitJSApi interface for simulator API interaction. - Updated lesson page to support circuit simulation alongside code execution. - Implemented separate output states for code and circuit evaluations. - Enhanced lesson content rendering to include circuit embeds using markdown. - Refactored backend to process circuit embed syntax and convert to HTML. - Updated podman-compose configuration to include new environment variable for cursor offset. - Created a proposal document outlining the feasibility and implementation plan for CircuitJS1 integration. --- documentation.md | 216 +++++++- elemes.sh | 13 + frontend/Dockerfile | 2 +- frontend/src/app.css | 21 + frontend/src/hooks.server.ts | 1 - .../src/lib/actions/renderCircuitEmbeds.ts | 72 +++ .../src/lib/components/CircuitEditor.svelte | 69 ++- frontend/src/lib/components/CodeEditor.svelte | 1 - .../lib/components/CrosshairOverlay.svelte | 418 +++++++++++++++ frontend/src/lib/components/LessonList.svelte | 87 ++++ .../src/lib/components/OutputPanel.svelte | 118 ++++- .../src/lib/components/WorkspaceHeader.svelte | 248 +++++++++ frontend/src/lib/types/circuitjs.ts | 24 + .../src/routes/lesson/[slug]/+page.svelte | 475 +++++------------- frontend/static/circuitjs1/circuitjs.html | 3 - podman-compose.yml | 1 + proposal.md | 123 +++++ services/lesson_service.py | 44 ++ 18 files changed, 1535 insertions(+), 401 deletions(-) create mode 100755 elemes.sh create mode 100644 frontend/src/lib/actions/renderCircuitEmbeds.ts create mode 100644 frontend/src/lib/components/CrosshairOverlay.svelte create mode 100644 frontend/src/lib/components/LessonList.svelte create mode 100644 frontend/src/lib/components/WorkspaceHeader.svelte create mode 100644 frontend/src/lib/types/circuitjs.ts create mode 100644 proposal.md diff --git a/documentation.md b/documentation.md index 05c3476..943141f 100644 --- a/documentation.md +++ b/documentation.md @@ -1,7 +1,7 @@ # Elemes LMS — Dokumentasi Teknis **Project:** LMS-C (Learning Management System untuk Pemrograman C) -**Terakhir diupdate:** 25 Maret 2026 +**Terakhir diupdate:** 30 Maret 2026 --- @@ -17,6 +17,7 @@ Tailscale Funnel (elemes-ts) SvelteKit Frontend (elemes-frontend :3000) ├── SSR pages (lesson content embedded in HTML) ├── CodeMirror 6 editor (lazy-loaded) + ├── CircuitJS simulator (iframe, GWT-compiled) ├── API proxy: /api/* → Flask └── PWA manifest │ @@ -87,6 +88,8 @@ lms-c/ ├── app.css ├── lib/ │ ├── components/ + │ │ ├── CircuitEditor.svelte # CircuitJS iframe wrapper + │ │ ├── CrosshairOverlay.svelte # Touch precision overlay (fat finger fix) │ │ ├── CodeEditor.svelte # CodeMirror 6 (lazy-loaded) │ │ ├── Navbar.svelte │ │ ├── LessonCard.svelte @@ -101,7 +104,8 @@ lms-c/ │ └── types/ │ ├── lesson.ts │ ├── auth.ts - │ └── compiler.ts + │ ├── compiler.ts + │ └── circuitjs.ts # CircuitJSApi interface ├── routes/ │ ├── +layout.svelte │ ├── +page.svelte # Home (lesson grid) @@ -112,7 +116,14 @@ lms-c/ │ └── progress/ │ └── +page.svelte # Teacher dashboard └── static/ - └── manifest.json # PWA manifest + ├── manifest.json # PWA manifest + └── circuitjs1/ # CircuitJS simulator (GWT-compiled) + ├── circuitjs.html # GWT entry point (loaded in iframe) + ├── lz-string.min.js # LZ compression (circuit export) + └── circuitjs1/ # GWT compiled output + ├── *.cache.js # Compiled permutations + ├── circuitjs1.nocache.js # Bootstrap loader + └── circuits/ # Bundled example circuits ``` --- @@ -134,6 +145,14 @@ Podman's aardvark-dns tidak berfungsi di environment ini (getaddrinfo EAI_AGAIN) ### Kenapa hooks.server.ts untuk API proxy? Vite `server.proxy` hanya bekerja di dev mode (`vite dev`). Di production (adapter-node), SvelteKit tidak punya proxy. `hooks.server.ts` mem-forward `/api/*` ke Flask backend saat runtime. +### Kenapa transparent overlay untuk touch, bukan inject ke iframe? +CircuitJS adalah GWT-compiled app — tidak bisa modify source code-nya. Alternatif lain: +- **Inject script via `contentDocument`**: Fragile, GWT overwrite DOM handlers. +- **PostMessage**: Tidak bisa dispatch native MouseEvent dari luar. +- **Transparent overlay**: Intercept PointerEvent di parent, konversi ke MouseEvent, dispatch ke iframe via `contentDocument.elementFromPoint()`. Paling reliable karena tidak bergantung pada internal CircuitJS. + +Overlay dinonaktifkan (`pointer-events: none`) di desktop sehingga mouse events langsung tembus ke iframe tanpa overhead. + ### Kenapa lazy-load CodeMirror? CodeMirror 6 bundle ~475KB. Dengan dynamic `import()`, lesson content (text) muncul langsung via SSR, editor menyusul setelah JS bundle selesai download. Perceived load time jauh lebih cepat. @@ -185,6 +204,131 @@ Semua endpoint Flask diakses via SvelteKit proxy (`/api/*` → Flask `:5000`): --- +## CircuitJS Integration + +Integrasi Falstad CircuitJS1 sebagai simulator rangkaian interaktif di dalam tab "Circuit" pada halaman lesson. CircuitJS adalah aplikasi GWT (Java → JavaScript) yang di-embed via iframe same-origin. + +### Arsitektur + +``` +content/z_test_circuit.md + │ ---INITIAL_CIRCUIT--- ... ---END_INITIAL_CIRCUIT--- + │ + ▼ lesson_service.py: _extract_section() +Flask API (/api/lesson/.json) + │ { initial_circuit, expected_output, key_text, active_tabs: ["circuit"] } + │ + ▼ +page.ts SSR loader +lesson/[slug]/+page.svelte + │ + │ + ▼ iframe onload → oncircuitjsloaded callback +CircuitEditor.svelte + │ simApi.importCircuit(text, false) + │ + ▼ +circuitjs1/circuitjs.html (GWT app in iframe) + │ Canvas rendering, simulasi real-time + │ + ▼ CrosshairOverlay.svelte (touch devices only) +Touch event forwarding → synthetic MouseEvent dispatch +``` + +### File-file Utama + +| File | Fungsi | +|------|--------| +| `frontend/static/circuitjs1/circuitjs.html` | GWT entry point, di-load dalam iframe | +| `frontend/static/circuitjs1/circuitjs1/circuitjs1.nocache.js` | GWT bootstrap — memilih permutation `.cache.js` berdasarkan browser | +| `frontend/static/circuitjs1/lz-string.min.js` | Kompresi LZ untuk circuit text export | +| `frontend/src/lib/components/CircuitEditor.svelte` | Wrapper iframe + API bridge | +| `frontend/src/lib/components/CrosshairOverlay.svelte` | Touch precision overlay | +| `services/lesson_service.py` | Parsing markdown, ekstraksi `---INITIAL_CIRCUIT---` | +| `routes/lessons.py` | API endpoint, serve `initial_circuit` ke frontend | +| `frontend/src/routes/lesson/[slug]/+page.svelte` | Evaluasi rangkaian (`evaluateCircuit()`) | + +### CircuitJS API (via `iframe.contentWindow.CircuitJS1`) + +API object didapatkan melalui callback `window.oncircuitjsloaded` yang dipanggil oleh GWT setelah inisialisasi, dengan fallback 3 detik via `window.CircuitJS1`. + +| Method | Fungsi | +|--------|--------| +| `importCircuit(text, showMessage)` | Load circuit dari text | +| `exportCircuit()` | Ekspor circuit saat ini sebagai text | +| `getNodeVoltage(nodeName)` | Query tegangan di named node | +| `setSimRunning(bool)` | Jalankan/hentikan simulasi | +| `updateCircuit()` | Redraw setelah perubahan | +| `elements()` | Jumlah elemen di circuit (belum dipakai) | +| `getElm(index)` | Ambil elemen berdasarkan index (belum dipakai) | + +### Circuit Text Format + +CircuitJS menggunakan format XML-like custom. Contoh dari `content/z_test_circuit.md`: + +```xml + + + + + + + +``` + +### Evaluasi Rangkaian + +Saat siswa klik "Cek Rangkaian", fungsi `evaluateCircuit()` di `+page.svelte` menjalankan validasi: + +| Langkah | Mekanisme | Sumber Data | +|---------|-----------|-------------| +| 1. Parse kriteria | `JSON.parse(data.expected_output)` | Markdown `---EXPECTED_OUTPUT---` | +| 2. Cek tegangan node | `simApi.getNodeVoltage(nodeName)` vs expected ± tolerance | `expected_output.nodes` | +| 3. Cek komponen wajib | `circuitEditor.getCircuitText()` → `checkKeyText()` (string contains) | Markdown `---KEY_TEXT---` | +| 4. Track progress | `POST /api/track-progress` (jika semua passed) | Auth token | + +**Expected Output JSON Format:** + +```json +{ + "nodes": { + "TestPoint_A": { "voltage": 2.5, "tolerance": 0.2 } + }, + "elements": {} +} +``` + +### Lesson Markdown Format (Circuit) + +Section-section yang dikenali oleh `lesson_service.py` untuk lesson circuit: + +| Section | Fungsi | +|---------|--------| +| `---INITIAL_CIRCUIT---` ... `---END_INITIAL_CIRCUIT---` | Circuit text awal yang dimuat ke simulator | +| `---SOLUTION_CIRCUIT---` ... `---END_SOLUTION_CIRCUIT---` | Solusi (ditampilkan setelah lesson selesai) | +| `---EXPECTED_OUTPUT---` ... `---END_EXPECTED_OUTPUT---` | JSON kriteria evaluasi (node voltages) | +| `---KEY_TEXT---` ... `---END_KEY_TEXT---` | Teks/komponen wajib (string matching pada circuit text) | +| `---EXERCISE---` | Instruksi untuk siswa (di bawah separator ini) | + +Keberadaan `---INITIAL_CIRCUIT---` secara otomatis menambahkan `'circuit'` ke `active_tabs[]`, yang menampilkan tab Circuit di halaman lesson. + +### Auto-save + +`CircuitEditor` mendukung auto-save ke `sessionStorage` (polling setiap 5 detik): + +- **Key:** `elemes_circuit_{slug}` (hanya saat user login & bukan mode solusi) +- **Restore:** Saat load, cek sessionStorage dulu, fallback ke `initialCircuit` prop +- **Export:** `simApi.exportCircuit()` → bandingkan dengan saved → simpan jika berbeda + +### Catatan Teknis + +- **GWT tidak butuh build step**: File `.cache.js` sudah ter-compile. Copy as-is ke `frontend/static/`. +- **Same-origin wajib**: `contentDocument` access membutuhkan iframe same-origin. CircuitJS di-serve dari `/circuitjs1/` path di SvelteKit static. +- **Callback discovery**: GWT memanggil `window.oncircuitjsloaded(api)` setelah inisialisasi. Ini lebih reliable daripada polling `window.CircuitJS1` yang mungkin belum tersedia. +- **TypeScript interface**: `CircuitJSApi` di `types/circuitjs.ts` mengetik semua method yang digunakan. `simApi` bertipe `CircuitJSApi | null`, bukan `any`. +- **Auto-save cleanup**: `setInterval` untuk auto-save di-cleanup via `$effect` return saat komponen destroy, mencegah memory leak. + +--- + ## Anti Copy-Paste System Sistem berlapis untuk mencegah siswa meng-copy konten pelajaran dan mem-paste kode dari sumber eksternal ke editor. @@ -220,6 +364,72 @@ Mencegah siswa mem-paste kode dari sumber eksternal ke code editor. Diaktifkan v --- +## Touch Crosshair System (Fat Finger Fix) + +Sistem overlay untuk memberikan presisi interaksi di iframe CircuitJS pada perangkat sentuh. Aktif hanya pada touch device (deteksi via CSS media query `hover: none` + `pointer: coarse`), tidak mengganggu interaksi mouse di desktop. + +**File:** `frontend/src/lib/components/CrosshairOverlay.svelte` +**Dimount di:** `frontend/src/lib/components/CircuitEditor.svelte` + +### Gesture Mapping + +| Gesture | Action | Keterangan | +|---------|--------|------------| +| Single tap | `click` | Delay 300ms (menunggu double/triple). Termasuk toolbar CircuitJS | +| Double tap | `dblclick` | Edit komponen (buka dialog edit di CircuitJS) | +| Triple tap | Right-click (`contextmenu`) | Fallback untuk right-click satu jari | +| Two-finger tap | Right-click (`contextmenu`) | Gesture natural untuk right-click | +| Long tap (400ms) | Crosshair aiming mode | 4-phase state machine untuk presisi drag | + +### State Machine (Crosshair Aiming) + +``` +idle → [long tap] → aiming_start (crosshair muncul, belum click) + │ + [release] → holding (mousedown dispatch di posisi crosshair) + │ │ + [long tap] → aiming_end [5s timeout] + │ │ │ + │ [release] ▼ + │ │ mouseup + idle + │ ▼ + │ idle (mouseup dispatch) + │ + [short tap] → mouseup + idle +``` + +### Cara Kerja Event Forwarding + +| Layer | Mekanisme | Tujuan | +|-------|-----------|--------| +| Overlay | `
` transparan dengan `pointer-events: auto` (touch only) | Intercept semua touch event sebelum iframe | +| Koordinat | `getBoundingClientRect()` → konversi viewport ke iframe-local | Akurasi posisi di dalam iframe | +| Target | `iframe.contentDocument.elementFromPoint(x, y)` | Temukan elemen yang tepat (canvas, toolbar, dialog) | +| Dispatch | `new MouseEvent()` dengan `view: iframe.contentWindow` | GWT CircuitJS menerima event seolah native | +| Focus | `focusIfEditable()` → `.focus()` pada input/textarea | Virtual keyboard muncul saat tap text field | + +### Konfigurasi + +| Variable | Default | Lokasi | Fungsi | +|----------|---------|--------|--------| +| `PUBLIC_CURSOR_OFFSET_Y` | `50` | `podman-compose.yml` | Offset Y crosshair dari posisi jari (pixel). Semakin besar, crosshair semakin jauh di atas jari | + +### Konstanta Internal + +| Nama | Nilai | Fungsi | +|------|-------|--------| +| `LONG_PRESS_MS` | 400ms | Durasi tahan untuk aktifkan crosshair | +| `DOUBLE_TAP_MS` | 300ms | Window waktu antara tap untuk deteksi double/triple | +| `HOLDING_TIMEOUT_MS` | 5000ms | Safety net — auto-mouseup jika terjebak di holding state | + +### Limitasi + +- **Single tap delay 300ms**: Trade-off untuk membedakan single/double/triple tap. Tidak bisa dihindari tanpa mengorbankan multi-tap detection. +- **Synthetic focus**: Virtual keyboard mungkin tidak muncul di semua browser karena `.focus()` pada elemen di dalam iframe tidak selalu dianggap "user gesture" oleh browser. +- **Same-origin only**: `contentDocument` access membutuhkan iframe same-origin. CircuitJS di-serve dari path yang sama (`/circuitjs1/`), jadi ini bukan masalah. + +--- + ## Status Implementasi - [x] **Phase 0:** Backend decomposition (monolith → Blueprints + services) diff --git a/elemes.sh b/elemes.sh new file mode 100755 index 0000000..b50916a --- /dev/null +++ b/elemes.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +case "$1" in +runbuild) + podman-compose --env-file ../.env up --build --force-recreate -d + ;;& +run) + podman-compose --env-file ../.env up -d + ;;& +stop) + podman-compose --env-file ../.env down + ;; +esac diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 2f0bc1f..892e2dc 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -6,7 +6,7 @@ COPY package.json ./ RUN npm install COPY . . -RUN npm run build +RUN npx svelte-kit sync && npm run build ## ── Runner ─────────────────────────────────────────────────────── FROM node:20-slim diff --git a/frontend/src/app.css b/frontend/src/app.css index 5ccffca..e5aea8b 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -158,6 +158,27 @@ img { overflow: hidden; } +/* ── Circuit embeds (rendered from ```circuit markdown fences) ── */ +.circuit-embed { + margin: 0.75rem 0; + border: 1px solid var(--color-border); + border-radius: var(--radius); + overflow: hidden; + position: relative; + background: var(--color-bg-secondary); +} +.circuit-embed-wrapper { + display: block; +} +.circuit-embed-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + color: var(--color-text-muted); + font-size: 0.85rem; +} + /* ── Responsive ───────────────────────────────────────────────── */ @media (max-width: 768px) { .container { diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index ad68323..eab7f92 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -6,7 +6,6 @@ */ import type { Handle } from '@sveltejs/kit'; -import { execSync } from 'child_process'; function resolveBackend(): string { const env = process.env.API_BACKEND; diff --git a/frontend/src/lib/actions/renderCircuitEmbeds.ts b/frontend/src/lib/actions/renderCircuitEmbeds.ts new file mode 100644 index 0000000..f6b9733 --- /dev/null +++ b/frontend/src/lib/actions/renderCircuitEmbeds.ts @@ -0,0 +1,72 @@ +/** + * Find `.circuit-embed` divs (generated by backend from ```circuit fences) + * and replace them with live CircuitJS iframes. Uses IntersectionObserver + * for lazy loading so multiple embeds don't all load at once. + */ + +function loadCircuitEmbed(div: HTMLElement) { + const width = div.dataset.width || '100%'; + const height = div.dataset.height || '400px'; + const dataEl = div.querySelector('.circuit-data'); + const circuitData = dataEl?.textContent?.trim() || ''; + + const wrapper = document.createElement('div'); + wrapper.className = 'circuit-embed-wrapper'; + wrapper.style.width = width; + wrapper.style.height = height; + + const iframe = document.createElement('iframe'); + iframe.src = '/circuitjs1/circuitjs.html'; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = 'none'; + iframe.title = 'Circuit Simulator'; + + const loadCircuit = (api: any) => { + if (!circuitData) return; + try { + api.importCircuit(circuitData, false); + api.setSimRunning(true); + api.updateCircuit(); + } catch (e) { + console.error('Circuit embed load error:', e); + } + const loadingEl = div.querySelector('.circuit-embed-loading'); + if (loadingEl) loadingEl.remove(); + }; + + iframe.onload = () => { + const win = iframe.contentWindow as any; + + win.oncircuitjsloaded = (api: any) => loadCircuit(api); + + // Fallback if callback doesn't fire + setTimeout(() => { + const api = win?.CircuitJS1; + if (api) loadCircuit(api); + }, 3000); + }; + + wrapper.appendChild(iframe); + div.appendChild(wrapper); + div.dataset.rendered = 'true'; +} + +export function renderCircuitEmbeds(container: HTMLElement) { + const embeds = container.querySelectorAll('.circuit-embed:not([data-rendered])'); + if (embeds.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + loadCircuitEmbed(entry.target as HTMLElement); + observer.unobserve(entry.target); + } + }); + }, + { rootMargin: '200px' } + ); + + embeds.forEach((el) => observer.observe(el)); +} diff --git a/frontend/src/lib/components/CircuitEditor.svelte b/frontend/src/lib/components/CircuitEditor.svelte index af2deaf..784aa41 100644 --- a/frontend/src/lib/components/CircuitEditor.svelte +++ b/frontend/src/lib/components/CircuitEditor.svelte @@ -1,17 +1,21 @@ @@ -162,6 +149,10 @@ class:visible={ready} > + {#if ready} + + {/if} + {#if storageKey && ready}
diff --git a/frontend/src/lib/components/CodeEditor.svelte b/frontend/src/lib/components/CodeEditor.svelte index 36a3bdf..e123b4a 100644 --- a/frontend/src/lib/components/CodeEditor.svelte +++ b/frontend/src/lib/components/CodeEditor.svelte @@ -348,7 +348,6 @@ font-weight: 500; } .editor-wrapper :global(.cm-editor) { -... min-height: 200px; max-height: 60vh; } diff --git a/frontend/src/lib/components/CrosshairOverlay.svelte b/frontend/src/lib/components/CrosshairOverlay.svelte new file mode 100644 index 0000000..3333c9b --- /dev/null +++ b/frontend/src/lib/components/CrosshairOverlay.svelte @@ -0,0 +1,418 @@ + + + +
+
+ +{#if showCrosshair} + + + + + + + + +{/if} + + diff --git a/frontend/src/lib/components/LessonList.svelte b/frontend/src/lib/components/LessonList.svelte new file mode 100644 index 0000000..76bb7a3 --- /dev/null +++ b/frontend/src/lib/components/LessonList.svelte @@ -0,0 +1,87 @@ + + +{#if lessons.length} +
+

Semua Pelajaran

+
+ {#each lessons as lesson (lesson.filename)} + + {#if lesson.completed} + + {/if} + {lesson.title} + + {/each} +
+
+{/if} + + diff --git a/frontend/src/lib/components/OutputPanel.svelte b/frontend/src/lib/components/OutputPanel.svelte index 0e0da32..34e331d 100644 --- a/frontend/src/lib/components/OutputPanel.svelte +++ b/frontend/src/lib/components/OutputPanel.svelte @@ -1,27 +1,90 @@ -
+
Output - {#if loading} + {#if anyLoading} Compiling... - {:else if success === true} + {:else if overallSuccess === true} Berhasil - {:else if success === false} + {:else if overallSuccess === false} Error {/if}
-
{#if loading}Mengompilasi dan menjalankan kode...{:else if error}{error}{:else if output}{output}{:else}Klik "Run" untuk menjalankan kode{/if}
+
+ + {#if hasCode} +
+ +
{#if code.loading}Mengompilasi dan menjalankan kode...{:else if code.error}{code.error}{:else if code.output}{code.output}{:else}Klik "Run" untuk menjalankan kode{/if}
+
+ {/if} + + + {#if hasCircuit} +
+ +
{#if circuit.loading}Mengevaluasi rangkaian...{:else if circuit.error}{circuit.error}{:else if circuit.output}{circuit.output}{:else}Klik "Cek Rangkaian" untuk mengevaluasi{/if}
+
+ {/if} + + + {#if !hasCode && !hasCircuit} +
Tidak ada output
+ {/if} +
diff --git a/frontend/src/lib/types/circuitjs.ts b/frontend/src/lib/types/circuitjs.ts new file mode 100644 index 0000000..84fbc48 --- /dev/null +++ b/frontend/src/lib/types/circuitjs.ts @@ -0,0 +1,24 @@ +/** + * Interface for CircuitJS1 simulator API exposed via iframe's window.CircuitJS1. + * Methods marked optional (?) may not exist depending on CircuitJS version. + */ +export interface CircuitJSApi { + /** Import circuit from text format */ + importCircuit(text: string, showMessage: boolean): void; + + /** Export current circuit as text */ + exportCircuit(): string; + + /** Set simulation running state */ + setSimRunning(running: boolean): void; + + /** Force circuit update/redraw */ + updateCircuit(): void; + + /** Get voltage at a named node */ + getNodeVoltage(nodeName: string): number; + + /** Alternative import methods (version-dependent) */ + setCircuitText?(text: string): void; + setupText?(text: string): void; +} diff --git a/frontend/src/routes/lesson/[slug]/+page.svelte b/frontend/src/routes/lesson/[slug]/+page.svelte index b0ae948..8cc4f61 100644 --- a/frontend/src/routes/lesson/[slug]/+page.svelte +++ b/frontend/src/routes/lesson/[slug]/+page.svelte @@ -5,12 +5,15 @@ import CircuitEditor from '$components/CircuitEditor.svelte'; import OutputPanel from '$components/OutputPanel.svelte'; import CelebrationOverlay from '$components/CelebrationOverlay.svelte'; + import WorkspaceHeader from '$components/WorkspaceHeader.svelte'; + import LessonList from '$components/LessonList.svelte'; import { compileCode, trackProgress } from '$services/api'; import { auth, authLoggedIn } from '$stores/auth'; import { lessonContext } from '$stores/lessonContext'; import { noSelect } from '$actions/noSelect'; import { createFloatingPanel } from '$actions/floatingPanel.svelte'; import { highlightAllCode } from '$actions/highlightCode'; + import { renderCircuitEmbeds } from '$actions/renderCircuitEmbeds'; import { tick } from 'svelte'; import type { LessonContent } from '$types/lesson'; @@ -23,10 +26,20 @@ let data = $state(null); let lessonCompleted = $state(false); let currentCode = $state(''); - let compileOutput = $state(''); - let compileError = $state(''); - let compiling = $state(false); - let compileSuccess = $state(null); + + // Separate output state for code and circuit + let codeOutput = $state(''); + let codeError = $state(''); + let codeLoading = $state(false); + let codeSuccess = $state(null); + + let circuitOutput = $state(''); + let circuitError = $state(''); + let circuitLoading = $state(false); + let circuitSuccess = $state(null); + + // Derived: any loading state (for disabling Run button) + let compiling = $derived(codeLoading || circuitLoading); // UI state let showSolution = $state(false); @@ -46,7 +59,6 @@ // Mobile state: 'hidden' (only handle bar), 'half' (60%), 'full' (100%) let isMobile = $state(false); let mobileMode = $state<'hidden' | 'half' | 'full'>('half'); - let touchStartY = 0; // Media query detection $effect(() => { @@ -64,29 +76,6 @@ return () => mql.removeEventListener('change', handler); }); - function cycleMobileSheet() { - if (mobileMode === 'hidden') mobileMode = 'half'; - else if (mobileMode === 'half') mobileMode = 'full'; - else mobileMode = 'hidden'; - } - - function onSheetTouchStart(e: TouchEvent) { - touchStartY = e.touches[0].clientY; - } - - function onSheetTouchEnd(e: TouchEvent) { - const delta = e.changedTouches[0].clientY - touchStartY; - if (delta > 60) { - // Swipe down: full→half→hidden - if (mobileMode === 'full') mobileMode = 'half'; - else mobileMode = 'hidden'; - } else if (delta < -60) { - // Swipe up: hidden→half→full - if (mobileMode === 'hidden') mobileMode = 'half'; - else mobileMode = 'full'; - } - } - const slug = $derived($page.params.slug); // Sync lesson data when navigating between lessons @@ -95,9 +84,12 @@ data = lesson; lessonCompleted = lesson.lesson_completed; currentCode = lesson.initial_code_c || lesson.initial_python || lesson.initial_code || ''; - compileOutput = ''; - compileError = ''; - compileSuccess = null; + codeOutput = ''; + codeError = ''; + codeSuccess = null; + circuitOutput = ''; + circuitError = ''; + circuitSuccess = null; showSolution = false; if (lesson.lesson_info) activeTab = 'info'; else if (lesson.exercise_content) activeTab = 'exercise'; @@ -120,12 +112,18 @@ lessonContext.set(null); }); - // Apply syntax highlighting after content renders + // Apply syntax highlighting + circuit embeds after content renders $effect(() => { if (data) { tick().then(() => { - if (contentEl) highlightAllCode(contentEl); - if (tabsEl) highlightAllCode(tabsEl); + if (contentEl) { + highlightAllCode(contentEl); + renderCircuitEmbeds(contentEl); + } + if (tabsEl) { + highlightAllCode(tabsEl); + renderCircuitEmbeds(tabsEl); + } }); } }); @@ -142,16 +140,16 @@ if (!data || !circuitEditor) return; const simApi = circuitEditor.getApi(); if (!simApi) { - compileError = "Simulator belum siap."; - compileSuccess = false; + circuitError = "Simulator belum siap."; + circuitSuccess = false; activeTab = 'output'; return; } - compiling = true; - compileOutput = 'Mengevaluasi rangkaian...'; - compileError = ''; - compileSuccess = null; + circuitLoading = true; + circuitOutput = 'Mengevaluasi rangkaian...'; + circuitError = ''; + circuitSuccess = null; activeTab = 'output'; try { @@ -161,16 +159,16 @@ expectedState = JSON.parse(data.expected_output); } } catch (e) { - compileError = "Format EXPECTED_OUTPUT tidak valid (Harus JSON)."; - compileSuccess = false; - compiling = false; + circuitError = "Format EXPECTED_OUTPUT tidak valid (Harus JSON)."; + circuitSuccess = false; + circuitLoading = false; return; } if (!expectedState) { - compileOutput = "Tidak ada kriteria evaluasi yang ditetapkan."; - compileSuccess = true; - compiling = false; + circuitOutput = "Tidak ada kriteria evaluasi yang ditetapkan."; + circuitSuccess = true; + circuitLoading = false; return; } @@ -185,7 +183,7 @@ messages.push(`❌ Node '${nodeName}' tidak ditemukan.`); continue; } - + const expectedV = criteria.voltage; const tol = criteria.tolerance || 0.1; if (Math.abs(actualV - expectedV) <= tol) { @@ -197,25 +195,8 @@ } } - if (expectedState.elements && typeof simApi.elements === 'function' && typeof simApi.getElm === 'function') { - const elmCount = simApi.elements(); - const elements = []; - for (let i = 0; i < elmCount; i++) { - elements.push(simApi.getElm(i)); - } - for (const [infoMatch, criteria] of Object.entries(expectedState.elements)) { - let found = null; - for (const el of elements) { - try { - const info = typeof el.getInfo === 'function' ? el.getInfo() : null; - // the info from getInfo is an array or something we might not be able to parse natively via JS. - // but we skip elements checking for now unless user really needs it - } catch (e) {} - } - } - } - - // End of elements check + // TODO: Element-level checking (e.g. expectedState.elements) belum diimplementasi. + // GWT getInfo() returns Java array yang sulit di-parse dari JS. const circuitText = circuitEditor.getCircuitText(); const keyTextMatch = checkKeyText(circuitText, data.key_text ?? ''); @@ -224,8 +205,8 @@ messages.push(`❌ Komponen wajib belum lengkap (lihat instruksi).`); } - compileOutput = messages.join('\n'); - compileSuccess = allPassed; + circuitOutput = messages.join('\n'); + circuitSuccess = allPassed; if (allPassed) { showCelebration = true; @@ -241,10 +222,10 @@ }, 3000); } } catch (err: any) { - compileError = `Evaluasi gagal: ${err.message}`; - compileSuccess = false; + circuitError = `Evaluasi gagal: ${err.message}`; + circuitSuccess = false; } finally { - compiling = false; + circuitLoading = false; } } @@ -254,10 +235,10 @@ return; } if (!data) return; - compiling = true; - compileOutput = ''; - compileError = ''; - compileSuccess = null; + codeLoading = true; + codeOutput = ''; + codeError = ''; + codeSuccess = null; activeTab = 'output'; try { @@ -265,8 +246,8 @@ const res = await compileCode({ code, language: data.language }); if (res.success) { - compileOutput = res.output; - compileSuccess = true; + codeOutput = res.output; + codeSuccess = true; if (data.expected_output) { const outputMatch = res.output.trim() === data.expected_output.trim(); @@ -291,14 +272,14 @@ } } } else { - compileError = res.error || 'Compilation failed'; - compileSuccess = false; + codeError = res.error || 'Compilation failed'; + codeSuccess = false; } } catch { - compileError = 'Gagal terhubung ke server'; - compileSuccess = false; + codeError = 'Gagal terhubung ke server'; + codeSuccess = false; } finally { - compiling = false; + codeLoading = false; } } @@ -306,13 +287,16 @@ if (!data) return; if (activeTab === 'circuit') { circuitEditor?.setCircuitText(data.initial_circuit || data.initial_code); + circuitOutput = ''; + circuitError = ''; + circuitSuccess = null; } else { currentCode = data.initial_code; editor?.setCode(data.initial_code); + codeOutput = ''; + codeError = ''; + codeSuccess = null; } - compileOutput = ''; - compileError = ''; - compileSuccess = null; } function handleShowSolution() { @@ -352,24 +336,7 @@ oncontextmenu={(e) => e.preventDefault()}>
{@html data.lesson_content}
- - {#if data.ordered_lessons?.length} -
-

Semua Pelajaran

-
- {#each data.ordered_lessons as lesson (lesson.filename)} - - {#if lesson.completed} - - {/if} - {lesson.title} - - {/each} -
-
- {/if} +
@@ -391,60 +358,23 @@ class:mobile-full={isMobile && mobileMode === 'full'} style={float.style}> - - {#if isMobile} - - {:else if float.floating && !float.minimized} - -
- - { e.stopPropagation(); float.onResizeStart(e); }} title="Resize">◳ - Workspace -
- - -
-
- {:else if !isMobile} -
- Workspace -
- -
-
- {/if} + -
- -
- {#if data.lesson_info} - - {/if} - {#if data.exercise_content} - - {/if} - {#if !data.active_tabs || data.active_tabs.length === 0 || data.active_tabs.includes('c') || data.active_tabs.includes('python')} - - {/if} - {#if data.active_tabs?.includes('circuit')} - - {/if} - -
+
@@ -537,10 +467,10 @@
@@ -625,64 +555,6 @@ margin-bottom: 0.5rem; } - /* ── All lessons list ──────────────────────────────────── */ - .all-lessons { - margin-top: 2rem; - padding-top: 1.5rem; - border-top: 1px solid var(--color-border); - } - .all-lessons-heading { - font-size: 0.9rem; - font-weight: 600; - color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.04em; - margin-bottom: 0.5rem; - } - .all-lessons-list { - display: flex; - flex-direction: column; - gap: 2px; - } - .lesson-item { - display: flex; - align-items: center; - gap: 0.4rem; - padding: 0.45rem 0.6rem; - border-radius: 6px; - font-size: 0.82rem; - color: var(--color-text); - text-decoration: none; - transition: background 0.12s; - } - .lesson-item:hover { - background: var(--color-bg-secondary); - text-decoration: none; - color: var(--color-text); - } - .lesson-item-active { - background: var(--color-primary); - color: #fff; - font-weight: 600; - } - .lesson-item-active:hover { - background: var(--color-primary-dark); - color: #fff; - } - .lesson-check { - color: var(--color-success); - font-size: 0.75rem; - flex-shrink: 0; - } - .lesson-item-active .lesson-check { - color: #fff; - } - .lesson-item-title { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - /* ── Editor area (docked mode) ──────────────────────────── */ .editor-area { position: sticky; @@ -774,63 +646,6 @@ transform: scale(0.95); } - /* ── Float toggle button ───────────────────────────────── */ - .btn-float-toggle { - background: none; - border: 1px solid var(--color-border); - border-radius: 4px; - padding: 0.2rem 0.5rem; - cursor: pointer; - font-size: 0.95rem; - color: var(--color-text-muted); - line-height: 1; - } - .btn-float-toggle:hover { - background: var(--color-bg-secondary); - color: var(--color-text); - } - - /* ── Panel header ───────────────────────────────────────── */ - .panel-header { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - background: var(--color-bg-secondary); - border-bottom: 1px solid var(--color-border); - user-select: none; - cursor: default; - flex-wrap: wrap; - } - .panel-header.draggable { - cursor: grab; - } - .panel-header.draggable:active { - cursor: grabbing; - } - .panel-title { - font-size: 0.85rem; - font-weight: 600; - flex: 1; - } - .panel-actions { - display: flex; - gap: 0.25rem; - } - .panel-btn { - background: none; - border: 1px solid var(--color-border); - border-radius: 4px; - padding: 0.15rem 0.5rem; - cursor: pointer; - font-size: 0.8rem; - color: var(--color-text); - line-height: 1; - } - .panel-btn:hover { - background: var(--color-border); - } - /* ── Desktop floating mode ─────────────────────────────── */ .editor-area.floating { position: fixed; @@ -850,29 +665,17 @@ flex-direction: column; overflow: hidden; } - .resize-handle { - cursor: nwse-resize; - font-size: 0.9rem; - color: var(--color-text-muted); - line-height: 1; - padding: 0.1rem 0.3rem; - border-radius: 3px; - } - .resize-handle:hover { - background: var(--color-border); - color: var(--color-text); - } .editor-area.floating-hidden { display: none !important; } - /* ── Mobile bottom sheet ───────────────────────────────── */ + /* ── Mobile bottom sheet ──────────────────────────────── */ .editor-area.mobile-sheet { position: fixed; + top: auto; bottom: 0; left: 0; right: 0; - top: auto; z-index: 9999; background: var(--color-bg); border-top: 2px solid var(--color-primary); @@ -880,66 +683,64 @@ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15); display: flex; flex-direction: column; - transition: max-height 0.3s ease, transform 0.3s ease; + overflow: hidden; + transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-radius 0.2s ease; } .editor-area.mobile-hidden { - max-height: 100vh; - transform: translateY(calc(100% - 48px)); + height: 52px; } .editor-area.mobile-half { - max-height: 60vh; - transform: translateY(0); + height: 60vh; } .editor-area.mobile-full { - max-height: calc(100vh - 3rem); - top: 3rem; + height: calc(100vh - 3rem); border-radius: 0; - transform: translateY(0); } .mobile-sheet .editor-body { overscroll-behavior: contain; } - .sheet-handle { - flex-direction: column; - border: none; - border-bottom: 1px solid var(--color-border); - cursor: pointer; - width: 100%; - color: inherit; - font: inherit; - text-align: center; - } - .sheet-handle-bar { - width: 36px; - height: 4px; - background: var(--color-border); - border-radius: 2px; - margin: 0 auto 0.25rem; - } - - /* ── Tabs ─────────────────────────────────────────────── */ - .panel-tabs { - display: flex; - gap: 0; - margin-bottom: 0.5rem; - border: 1px solid var(--color-border); - border-radius: var(--radius); - overflow: hidden; - } - .tab { + /* ── Mobile full: expand content to fill ────────────── */ + .editor-area.mobile-full .editor-body { flex: 1; - padding: 0.5rem; - border: none; - background: var(--color-bg-secondary); - color: var(--color-text); - cursor: pointer; - font-weight: 500; - font-size: 0.85rem; - white-space: nowrap; + display: flex; + flex-direction: column; + min-height: 0; } - .tab.active { - background: var(--color-primary); - color: #fff; + .editor-area.mobile-full .tab-panel:not(.tab-hidden) { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + .editor-area.mobile-full .panel { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + .editor-area.mobile-full :global(.circuit-container) { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + .editor-area.mobile-full :global(.circuit-wrapper) { + flex: 1; + height: auto; + } + .editor-area.mobile-full :global(.editor-wrapper) { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + .editor-area.mobile-full :global(.cm-editor) { + flex: 1; + max-height: none; + min-height: 0; + } + .editor-area.mobile-full :global(.cm-scroller) { + flex: 1; } /* ── Tab panels ────────────────────────────────────────── */ @@ -950,8 +751,4 @@ display: none; } - /* ── Utility ───────────────────────────────────────────── */ - .editor-body.body-hidden { - display: none; - } diff --git a/frontend/static/circuitjs1/circuitjs.html b/frontend/static/circuitjs1/circuitjs.html index a482133..18ad8e4 100644 --- a/frontend/static/circuitjs1/circuitjs.html +++ b/frontend/static/circuitjs1/circuitjs.html @@ -37,7 +37,6 @@ overflow: hidden; } .gwt-Frame{ - scrolling="no"; border: 0px !important; } @@ -64,8 +63,6 @@ color: LightGray; } -.gwt-Label-current { -} .gwt-Button.chbut { padding:3px; diff --git a/podman-compose.yml b/podman-compose.yml index a6b1498..ed0bf6f 100644 --- a/podman-compose.yml +++ b/podman-compose.yml @@ -28,6 +28,7 @@ services: - PUBLIC_APP_BAR_TITLE=${APP_BAR_TITLE} - PUBLIC_COPYRIGHT_TEXT=${COPYRIGHT_TEXT} - PUBLIC_PAGE_TITLE_SUFFIX=${PAGE_TITLE_SUFFIX} + - PUBLIC_CURSOR_OFFSET_Y=${CURSOR_OFFSET_Y:-50} depends_on: - elemes diff --git a/proposal.md b/proposal.md new file mode 100644 index 0000000..953d090 --- /dev/null +++ b/proposal.md @@ -0,0 +1,123 @@ +# Proposal: Studi Kelayakan dan Perencanaan Implementasi CircuitJS1 di Elemes LMS + +## Pendahuluan +Dokumen ini merupakan proposal dan studi kelayakan untuk mengintegrasikan **[CircuitJS1](https://github.com/pfalstad/circuitjs1)** (sebuah simulator rangkaian elektronik berbasis web) ke dalam ekosistem Elemes LMS. Tujuannya adalah untuk mentransisikan atau menambahkan kapabilitas LMS yang saat ini fokus pada pemrograman (Code Editor) menjadi platform pembelajaran rangkaian elektronik yang interaktif. + +--- + +## 1. Cara Implementasi dengan Sistem Elemes yang Sekarang + +Sistem Elemes saat ini terdiri dari Backend (Flask) dan Frontend (SvelteKit). Implementasi CircuitJS1 dapat dilakukan dengan mulus karena CircuitJS1 berjalan sepenuhnya di sisi klien (browser). + +### a. Integrasi Frontend (SvelteKit) +- **Komponen Baru (`CircuitEditor.svelte`):** Komponen `CodeEditor.svelte` (berbasis CodeMirror) akan diganti atau didampingi dengan komponen baru yang memuat CircuitJS1. +- **Metode Embed:** CircuitJS1 dapat diintegrasikan menggunakan elemen `