From c3fc8dc8f12b13ad9577b58ec3c4dadd78563635 Mon Sep 17 00:00:00 2001 From: a2nr Date: Sat, 5 Sep 2026 16:50:31 +0700 Subject: [PATCH] feat(teacher): integrate full Velxio editor with bi-directional circuit and code sync --- .../lib/components/TeacherVelxioEditor.svelte | 316 ++++++++++++++++++ .../lib/services/markdown/extractSections.ts | 28 ++ frontend/src/lib/services/markdown/index.ts | 2 + .../lib/services/markdown/markdown.test.ts | 16 +- frontend/src/lib/services/velxio-manager.ts | 76 +++-- .../src/routes/teacher/content/+page.svelte | 78 ++++- 6 files changed, 490 insertions(+), 26 deletions(-) create mode 100644 frontend/src/lib/components/TeacherVelxioEditor.svelte diff --git a/frontend/src/lib/components/TeacherVelxioEditor.svelte b/frontend/src/lib/components/TeacherVelxioEditor.svelte new file mode 100644 index 0000000..df70954 --- /dev/null +++ b/frontend/src/lib/components/TeacherVelxioEditor.svelte @@ -0,0 +1,316 @@ + + +
+
+
+ + + {isReady ? 'Simulator Siap (Mode Lengkap)' : 'Menghubungkan Simulator...'} + + {#if saveFeedback} + {saveFeedback} + {/if} +
+ +
+ + + +
+
+ + +
+ + diff --git a/frontend/src/lib/services/markdown/extractSections.ts b/frontend/src/lib/services/markdown/extractSections.ts index 7267cc4..916b132 100644 --- a/frontend/src/lib/services/markdown/extractSections.ts +++ b/frontend/src/lib/services/markdown/extractSections.ts @@ -23,3 +23,31 @@ export function extractSection( return [extracted, remaining]; } + +/** + * Update the inner content between startMarker and endMarker in content, + * or append the section block at the end if the markers are not present. + */ +export function upsertSection( + content: string, + startMarker: string, + endMarker: string, + newInnerContent: string +): string { + const trimmedInner = newInnerContent.trim(); + const startIdx = content.indexOf(startMarker); + const endIdx = content.indexOf(endMarker); + + if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { + return ( + content.substring(0, startIdx + startMarker.length) + + '\n' + + trimmedInner + + '\n' + + content.substring(endIdx) + ); + } else { + const trimmedContent = content.trimEnd(); + return `${trimmedContent}\n\n${startMarker}\n${trimmedInner}\n${endMarker}\n`; + } +} diff --git a/frontend/src/lib/services/markdown/index.ts b/frontend/src/lib/services/markdown/index.ts index f826829..fd96d5a 100644 --- a/frontend/src/lib/services/markdown/index.ts +++ b/frontend/src/lib/services/markdown/index.ts @@ -161,3 +161,5 @@ export function renderMarkdownPreview(rawMarkdown: string): RenderedPreview { }; } } + +export { extractSection, upsertSection } from './extractSections'; diff --git a/frontend/src/lib/services/markdown/markdown.test.ts b/frontend/src/lib/services/markdown/markdown.test.ts index 9da650b..f286c93 100644 --- a/frontend/src/lib/services/markdown/markdown.test.ts +++ b/frontend/src/lib/services/markdown/markdown.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { extractSection } from './extractSections'; +import { extractSection, upsertSection } from './extractSections'; import { detectActiveTabs } from './detectActiveTabs'; import { processCircuitEmbeds, processFlowchartEmbeds, processEmbedEmbeds } from './processEmbeds'; import { parseSlides } from './parseSlides'; @@ -23,6 +23,20 @@ describe('Markdown Renderer & Extractor Tests', () => { }); }); + describe('upsertSection', () => { + it('should replace existing section content while preserving surrounding text', () => { + const input = 'Header\n---INITIAL_CODE_ARDUINO---\nold code\n---END_INITIAL_CODE_ARDUINO---\nFooter'; + const result = upsertSection(input, '---INITIAL_CODE_ARDUINO---', '---END_INITIAL_CODE_ARDUINO---', 'new code\nvoid setup() {}'); + expect(result).toBe('Header\n---INITIAL_CODE_ARDUINO---\nnew code\nvoid setup() {}\n---END_INITIAL_CODE_ARDUINO---\nFooter'); + }); + + it('should append section to content if markers do not exist', () => { + const input = 'Header content'; + const result = upsertSection(input, '---VELXIO_CIRCUIT---', '---END_VELXIO_CIRCUIT---', '{"board": "arduino:avr:uno"}'); + expect(result).toBe('Header content\n\n---VELXIO_CIRCUIT---\n{"board": "arduino:avr:uno"}\n---END_VELXIO_CIRCUIT---\n'); + }); + }); + describe('detectActiveTabs', () => { it('should detect velxio mode exclusively if INITIAL_CODE_ARDUINO is present', () => { const input = '---INITIAL_CODE_ARDUINO---\nvoid setup() {}\n---END_INITIAL_CODE_ARDUINO---\n---INITIAL_CODE---\nint main() {}\n---END_INITIAL_CODE---'; diff --git a/frontend/src/lib/services/velxio-manager.ts b/frontend/src/lib/services/velxio-manager.ts index 79d145e..ba3db10 100644 --- a/frontend/src/lib/services/velxio-manager.ts +++ b/frontend/src/lib/services/velxio-manager.ts @@ -1,39 +1,77 @@ import { VelxioBridge } from '$services/velxio-bridge'; import type { LessonContent } from '$types/lesson'; +const BOARD_KIND_MAP: Record = { + 'arduino-uno': 'arduino:avr:uno', + 'arduino-nano': 'arduino:avr:nano', + 'arduino-mega': 'arduino:avr:mega', + 'raspberry-pi-pico': 'rp2040:rp2040:rp2040', + 'rp2040-pico': 'rp2040:rp2040:rp2040', + 'esp32': 'esp32:esp32:esp32' +}; + export function getVelxioState(velxioIframe: HTMLIFrameElement | null): { code: string; circuit: string } | null { if (!velxioIframe) return null; try { const win = velxioIframe.contentWindow as any; if (!win) return null; - + const editorStore = win.__VELXIO_EDITOR_STORE__?.getState?.(); const simStore = win.__VELXIO_SIMULATOR_STORE__?.getState?.(); - + if (!editorStore || !simStore) return null; - - // Extract code - const code = (editorStore.files as any[] || []).map((f: any) => f.content).join('\n'); - + + // Extract active board and determine FQBN / board string + const activeBoard = simStore.boards?.find((b: any) => b.id === simStore.activeBoardId) ?? simStore.boards?.[0]; + const boardKind = activeBoard?.boardKind || simStore.activeBoardId || 'arduino-uno'; + const board = BOARD_KIND_MAP[boardKind] || (typeof boardKind === 'string' && boardKind.includes(':') ? boardKind : 'arduino:avr:uno'); + + // Extract code from active board file group or fallback to primary .ino / files + const activeGroupId = activeBoard?.activeFileGroupId ?? ''; + const activeFiles = (editorStore.fileGroups?.[activeGroupId]?.length + ? editorStore.fileGroups[activeGroupId] + : editorStore.files) ?? editorStore.files ?? []; + + const code = activeFiles.find((f: any) => f.name?.endsWith('.ino'))?.content + ?? activeFiles[0]?.content + ?? (editorStore.files as any[] || []).map((f: any) => f.content).join('\n') + ?? ''; + // Extract circuit state (Diagram format compatible with elemes:load_circuit) + const components = (simStore.components as any[] || []).map((c: any) => ({ + type: c.metadataId, + id: c.id, + x: Math.round((c.x || 0) * 10) / 10, + y: Math.round((c.y || 0) * 10) / 10, + rotation: c.properties?.rotation || 0, + props: { ...c.properties } + })); + + const wires = (simStore.wires as any[] || []) + .filter((w: any) => w && w.start && w.end) + .map((w: any) => ({ + start: { + componentId: w.start?.componentId, + pinName: w.start?.pinName + }, + end: { + componentId: w.end?.componentId, + pinName: w.end?.pinName + } + })); + const circuit = { - board: simStore.activeBoardId, - components: (simStore.components as any[] || []).map((c: any) => ({ - type: c.metadataId, - id: c.id, - x: c.x, - y: c.y, - rotation: c.properties?.rotation || 0, - props: { ...c.properties } - })), - wires: simStore.wires || [] + board, + components, + wires }; - + return { code, - circuit: JSON.stringify(circuit) + circuit: JSON.stringify(circuit, null, 2) }; - } catch { + } catch (e) { + console.error('[velxio-manager] Error getting Velxio state:', e); return null; } } diff --git a/frontend/src/routes/teacher/content/+page.svelte b/frontend/src/routes/teacher/content/+page.svelte index b56e4dc..9d2bdf9 100644 --- a/frontend/src/routes/teacher/content/+page.svelte +++ b/frontend/src/routes/teacher/content/+page.svelte @@ -4,18 +4,21 @@ import { createFloatingPanel } from '$actions/floatingPanel.svelte'; import CodeEditor from '$components/CodeEditor.svelte'; import CircuitEditor from '$components/CircuitEditor.svelte'; + import TeacherVelxioEditor from '$components/TeacherVelxioEditor.svelte'; import ContentFileTree from '$components/ContentFileTree.svelte'; import LessonContentView from '$components/LessonContentView.svelte'; import QuizPreviewReadonly from '$components/QuizPreviewReadonly.svelte'; import { ContentEditorManager } from '$services/contentEditor.svelte'; import { getTree, createFolder, createFile, renameEntry, deleteEntry, uploadAsset } from '$services/contentEditor'; import type { TreeNode } from '$services/contentEditor'; + import { upsertSection } from '$services/markdown'; const mgr = new ContentEditorManager(); const float = createFloatingPanel(); let editorRef = $state(null); let circuitEditorRef = $state(null); + let velxioEditorRef = $state(null); // Mobile: show tree or editor let mobileShowTree = $state(true); @@ -83,6 +86,8 @@ return codes; })()); + let extractedCircuitVelxio = $derived(extractCode(mgr.body, 'VELXIO_CIRCUIT')); + // Tab label map for chrome tabs let allTabs = $derived([ { id: 'editor', label: 'Editor' }, @@ -167,7 +172,66 @@ } } + function syncVelxioToMarkdown(): boolean { + if (!velxioEditorRef) return false; + const state = velxioEditorRef.getCurrentState(); + if (!state) return false; + applyVelxioToBody(state.code, state.circuit); + return true; + } + + function applyVelxioToBody(code: string, circuitJson: string) { + let currentBody = mgr.body; + + if (code !== undefined) { + currentBody = upsertSection( + currentBody, + '---INITIAL_CODE_ARDUINO---', + '---END_INITIAL_CODE_ARDUINO---', + code.trim() + ); + } + + if (circuitJson !== undefined) { + currentBody = upsertSection( + currentBody, + '---VELXIO_CIRCUIT---', + '---END_VELXIO_CIRCUIT---', + circuitJson.trim() + ); + } + + if (currentBody !== mgr.body) { + mgr.onBodyChange(currentBody); + if (editorRef) { + editorRef.setCode(currentBody); + } + } + } + + async function handleSaveVelxioFromTab(state: { code: string; circuit: string }) { + applyVelxioToBody(state.code, state.circuit); + await mgr.handleSave(); + } + + async function handleSaveAction() { + if (mgr.activeTab === 'velxio') { + syncVelxioToMarkdown(); + } + await mgr.handleSave(); + } + + async function handlePublishAction() { + if (mgr.activeTab === 'velxio') { + syncVelxioToMarkdown(); + } + await mgr.handlePublish(); + } + function handleTabClick(tab: string) { + if (mgr.activeTab === 'velxio' && tab !== 'velxio') { + syncVelxioToMarkdown(); + } mgr.activeTab = tab; if (mgr.isMobile && mgr.mobileMode === 'hidden') { mgr.mobileMode = 'h50'; @@ -584,10 +648,10 @@ {#snippet saveActions()} {#if mgr.activePath && !isAssetFile} - - {/if} @@ -802,10 +866,12 @@ {:else if dynTab.id === 'velxio'}
- +
{:else if dynTab.id === 'flowchart'}