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 @@
+
+
+
+
+
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}
-