feat(teacher): integrate full Velxio editor with bi-directional circuit and code sync

This commit is contained in:
a2nr 2026-09-05 16:50:31 +07:00
parent 2a152c8559
commit c3fc8dc8f1
6 changed files with 490 additions and 26 deletions

View File

@ -0,0 +1,316 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getVelxioState } from '$services/velxio-manager';
let {
initialCode = '',
initialCircuit = '',
onSave
}: {
initialCode?: string;
initialCircuit?: string;
onSave?: (state: { code: string; circuit: string }) => void;
} = $props();
let iframeRef = $state<HTMLIFrameElement | null>(null);
let isReady = $state(false);
let saveFeedback = $state<string | null>(null);
let feedbackTimeout: ReturnType<typeof setTimeout> | null = null;
const velxioSrc = '/velxio/editor?embed=true';
export function getCurrentState(): { code: string; circuit: string } | null {
return getVelxioState(iframeRef);
}
export function loadStateToSimulator(code: string, circuit: string) {
if (!iframeRef?.contentWindow) return;
const win = iframeRef.contentWindow;
// Teacher mode: embed mode with components and editor fully editable
win.postMessage(
{
type: 'elemes:set_embed_mode',
hideAuth: true,
hideComponentPicker: false,
lockComponents: false
},
'*'
);
if (circuit) {
try {
const circuitData = typeof circuit === 'string' ? JSON.parse(circuit) : circuit;
win.postMessage({ type: 'elemes:load_circuit', ...circuitData }, '*');
} catch (err) {
console.error('[TeacherVelxioEditor] Error parsing circuit JSON:', err);
}
}
if (code) {
win.postMessage(
{
type: 'elemes:load_code',
files: [{ name: 'sketch.ino', content: code }]
},
'*'
);
}
}
export function reloadFromDocument() {
loadStateToSimulator(initialCode, initialCircuit);
showFeedback('Dimuat ulang dari dokumen');
}
function handleSave() {
const state = getCurrentState();
if (!state) {
showFeedback('Gagal membaca state simulator');
return;
}
if (onSave) {
onSave(state);
}
showFeedback('Tersimpan ke dokumen!');
}
function showFeedback(msg: string) {
saveFeedback = msg;
if (feedbackTimeout) clearTimeout(feedbackTimeout);
feedbackTimeout = setTimeout(() => {
saveFeedback = null;
}, 2500);
}
function handleIframeLoad() {
checkIframeReady();
}
function checkIframeReady() {
try {
const win = iframeRef?.contentWindow as any;
if (!win || !win.document) return;
const root = win.document.getElementById('root');
if (root && root.children.length > 0) {
if (!isReady) {
isReady = true;
loadStateToSimulator(initialCode, initialCircuit);
}
}
} catch {
// Cross-origin fallback or not yet loaded
}
}
onMount(() => {
const onMessage = (e: MessageEvent) => {
if (e.data?.type === 'velxio:ready') {
isReady = true;
loadStateToSimulator(initialCode, initialCircuit);
}
};
window.addEventListener('message', onMessage);
const poll = setInterval(() => {
if (isReady) {
clearInterval(poll);
return;
}
checkIframeReady();
}, 600);
const pollTimeout = setTimeout(() => {
clearInterval(poll);
}, 15000);
return () => {
window.removeEventListener('message', onMessage);
clearInterval(poll);
clearTimeout(pollTimeout);
if (feedbackTimeout) clearTimeout(feedbackTimeout);
};
});
</script>
<div class="teacher-velxio-container">
<div class="teacher-velxio-toolbar">
<div class="toolbar-status">
<span class="status-dot" class:ready={isReady}></span>
<span class="status-text">
{isReady ? 'Simulator Siap (Mode Lengkap)' : 'Menghubungkan Simulator...'}
</span>
{#if saveFeedback}
<span class="save-feedback">{saveFeedback}</span>
{/if}
</div>
<div class="toolbar-actions">
<button
type="button"
class="toolbar-btn btn-reload"
onclick={reloadFromDocument}
title="Muat ulang rangkaian & kode dari dokumen markdown"
disabled={!isReady}
>
<svg class="icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 4v6h-6"></path>
<path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
<span>Muat Ulang</span>
</button>
<button
type="button"
class="toolbar-btn btn-save"
onclick={handleSave}
title="Simpan rangkaian dan kode saat ini langsung ke dokumen markdown"
disabled={!isReady}
>
<svg class="icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
<span>Simpan ke Dokumen</span>
</button>
</div>
</div>
<iframe
bind:this={iframeRef}
class="velxio-iframe"
src={velxioSrc}
title="Velxio Arduino Simulator & Editor"
onload={handleIframeLoad}
allow="cross-origin-isolated; fullscreen"
allowfullscreen
></iframe>
</div>
<style>
.teacher-velxio-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--color-bg, #ffffff);
}
.teacher-velxio-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 14px;
background: var(--color-bg-secondary, #f8f9fa);
border-bottom: 1px solid var(--color-border, #dee2e6);
font-size: 0.82rem;
min-height: 40px;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-status {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #ffc107;
display: inline-block;
transition: background-color 0.2s ease;
}
.status-dot.ready {
background: #198754;
box-shadow: 0 0 6px rgba(25, 135, 84, 0.4);
}
.status-text {
color: var(--color-text-muted, #555555);
font-weight: 500;
}
.save-feedback {
margin-left: 8px;
padding: 2px 8px;
background: rgba(25, 135, 84, 0.12);
color: #198754;
border-radius: 4px;
font-size: 0.78rem;
font-weight: 600;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--color-border, #dee2e6);
background: var(--color-bg, #ffffff);
color: var(--color-text, #111111);
transition: all 0.15s ease;
}
.toolbar-btn:hover:not(:disabled) {
background: var(--color-bg-secondary, #f0f0f0);
}
.toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-save {
background: var(--color-primary, #0d6efd);
color: #ffffff;
border-color: var(--color-primary, #0d6efd);
}
.btn-save:hover:not(:disabled) {
background: var(--color-primary-dark, #0a58ca);
border-color: var(--color-primary-dark, #0a58ca);
}
.icon {
flex-shrink: 0;
}
.velxio-iframe {
flex: 1;
width: 100%;
height: 100%;
border: none;
display: block;
}
</style>

View File

@ -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`;
}
}

View File

@ -161,3 +161,5 @@ export function renderMarkdownPreview(rawMarkdown: string): RenderedPreview {
};
}
}
export { extractSection, upsertSection } from './extractSections';

View File

@ -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---';

View File

@ -1,39 +1,77 @@
import { VelxioBridge } from '$services/velxio-bridge';
import type { LessonContent } from '$types/lesson';
const BOARD_KIND_MAP: Record<string, string> = {
'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;
}
}

View File

@ -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<any>(null);
let circuitEditorRef = $state<any>(null);
let velxioEditorRef = $state<any>(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}
<button class="panel-btn toolbar-toggle" class:active={featureSidebarOpen} onclick={() => featureSidebarOpen = !featureSidebarOpen} title="Tampilkan/sembunyikan panel Fitur">🧩</button>
<button class="panel-btn save-btn" onclick={() => mgr.handleSave()} disabled={mgr.saving || !mgr.dirty} title="Simpan">
<button class="panel-btn save-btn" onclick={() => handleSaveAction()} disabled={mgr.saving || !mgr.dirty} title="Simpan">
{mgr.saving ? '⏳' : '💾'}
</button>
<button class="panel-btn publish-btn" onclick={() => mgr.handlePublish()} disabled={mgr.publishing || !mgr.draftId} title="Publish">
<button class="panel-btn publish-btn" onclick={() => handlePublishAction()} disabled={mgr.publishing || !mgr.draftId} title="Publish">
{mgr.publishing ? '⏳' : '🚀'}
</button>
{/if}
@ -802,10 +866,12 @@
</div>
{:else if dynTab.id === 'velxio'}
<div class="velxio-tab-content">
<iframe class="velxio-iframe" src="/velxio/editor?embed=true&hideEditor=true&lockComponents=true" title="Velxio Arduino Editor"
allow="cross-origin-isolated; fullscreen"
allowfullscreen
></iframe>
<TeacherVelxioEditor
bind:this={velxioEditorRef}
initialCode={extractedCodes.velxio}
initialCircuit={extractedCircuitVelxio}
onSave={handleSaveVelxioFromTab}
/>
</div>
{:else if dynTab.id === 'flowchart'}
<div class="flowchart-tab-content">