feat: playground multi-tab (Velxio/Flowchart/Circuit/Code) with auto-save

This commit is contained in:
a2nr 2026-07-24 13:45:28 +07:00
parent 7e1139e7d1
commit 589f3a148d
1 changed files with 183 additions and 31 deletions

View File

@ -1,12 +1,23 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import DeployFAB from '$components/DeployFAB.svelte';
import CircuitEditor from '$components/CircuitEditor.svelte';
import CodeEditor from '$components/CodeEditor.svelte';
import FlowchartTab from '../lesson/[slug]/FlowchartTab.svelte';
type PlaygroundTab = 'velxio' | 'flowchart' | 'circuit' | 'code';
let activeTab = $state<PlaygroundTab>('velxio');
let codeLanguage = $state<'c' | 'python'>('c');
let codeEditor = $state<CodeEditor | null>(null);
let currentCode = $state('');
// ── Velxio + DeployFAB ──
let velxioIframe = $state<HTMLIFrameElement | null>(null);
type DeployFABHandle = { setHex: (hex: string | null) => void };
let fabComponent = $state<DeployFABHandle | null>(null);
const iframeSrc = '/velxio/editor?embed=true&desktopLayout=true';
const velxioSrc = '/velxio/editor?embed=true&desktopLayout=true';
function handleMessage(e: MessageEvent) {
if (!e.data?.type) return;
@ -32,6 +43,14 @@
velxioIframe?.contentWindow?.postMessage({ type: 'elemes:stop_hardware_serial' }, window.location.origin);
}
// ── Storage keys ──
const FLOWCHART_KEY = 'playground_flowchart_draft';
const CIRCUIT_KEY = 'playground_circuit_draft';
const CODE_C_KEY = 'playground_code_c_draft';
const CODE_PY_KEY = 'playground_code_python_draft';
let codeStorageKey = $derived(codeLanguage === 'python' ? CODE_PY_KEY : CODE_C_KEY);
onMount(() => {
window.addEventListener('message', handleMessage);
});
@ -42,19 +61,69 @@
</script>
<svelte:head>
<title>Developer Playground — Velxio</title>
<title>Developer Playground — Elemes</title>
</svelte:head>
<div class="playground-page">
<!-- Tab bar -->
<div class="pg-tabs">
<button class="pg-tab" class:active={activeTab === 'velxio'} onclick={() => activeTab = 'velxio'}>
Arduino
</button>
<button class="pg-tab" class:active={activeTab === 'flowchart'} onclick={() => activeTab = 'flowchart'}>
Flowchart
</button>
<button class="pg-tab" class:active={activeTab === 'circuit'} onclick={() => activeTab = 'circuit'}>
Circuit
</button>
<div class="pg-tab-group">
<button class="pg-tab pg-tab-sub" class:active={activeTab === 'code' && codeLanguage === 'c'} onclick={() => { activeTab = 'code'; codeLanguage = 'c'; }}>
C
</button>
<button class="pg-tab pg-tab-sub" class:active={activeTab === 'code' && codeLanguage === 'python'} onclick={() => { activeTab = 'code'; codeLanguage = 'python'; }}>
Python
</button>
</div>
</div>
<!-- Content area -->
<div class="pg-content">
{#if activeTab === 'velxio'}
<iframe
bind:this={velxioIframe}
src={iframeSrc}
src={velxioSrc}
title="Velxio Editor"
allow="bluetooth; clipboard-read; clipboard-write; fullscreen"
class="velxio-iframe"
class="pg-iframe"
allowfullscreen
></iframe>
{:else if activeTab === 'flowchart'}
<FlowchartTab
storageKey={FLOWCHART_KEY}
/>
{:else if activeTab === 'circuit'}
<div class="pg-circuit-wrap">
<CircuitEditor
storageKey={CIRCUIT_KEY}
/>
</div>
{:else if activeTab === 'code'}
<div class="pg-code-wrap">
{#key codeLanguage}
<CodeEditor
bind:this={codeEditor}
code={currentCode}
language={codeLanguage}
storageKey={codeStorageKey}
onchange={(val) => currentCode = val}
/>
{/key}
</div>
{/if}
</div>
<!-- DeployFAB only on Velxio tab -->
{#if activeTab === 'velxio'}
<DeployFAB
bind:this={fabComponent}
{velxioIframe}
@ -62,36 +131,119 @@
onSerialStart={notifySerialStart}
onStopSerial={notifySerialStop}
/>
{/if}
</div>
<style>
.playground-page {
width: 100%;
height: 100dvh;
overflow-y: auto;
overflow-x: hidden;
overflow: hidden;
display: flex;
flex-direction: column;
}
.velxio-iframe {
width: 100%;
flex: 1;
min-height: 0; /* critical: allows flex child to shrink below content size */
border: none;
display: block;
touch-action: pan-y; /* allow vertical pan inside iframe */
/* ── Tab bar ── */
.pg-tabs {
display: flex;
align-items: flex-end;
gap: 2px;
padding: 0 8px;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
min-height: 36px;
flex-shrink: 0;
}
.pg-tab {
padding: 5px 16px;
border: 1px solid transparent;
border-bottom: none;
border-radius: 8px 8px 0 0;
background: transparent;
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
margin-bottom: -1px;
z-index: 0;
transition: background 0.15s, color 0.15s;
}
.pg-tab:hover:not(.active) {
background: var(--color-border);
color: var(--color-text);
}
.pg-tab.active {
background: var(--color-bg);
color: var(--color-text);
font-weight: 600;
border-color: var(--color-border);
z-index: 1;
}
.pg-tab-group {
display: flex;
gap: 0;
align-items: flex-end;
}
.pg-tab-sub {
padding: 5px 12px;
}
/* ── Content area ── */
.pg-content {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
}
.pg-iframe {
width: 100%;
flex: 1;
border: none;
display: block;
min-height: 0;
}
.pg-circuit-wrap {
width: 100%;
height: 100%;
overflow: auto;
padding: 8px;
}
.pg-code-wrap {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ── FlowchartTab fill ── */
.pg-content :global(.flowchart-container) {
flex: 1;
min-height: 0;
}
/* ── Mobile ── */
@media (max-width: 768px) {
.playground-page {
overflow-y: auto;
height: auto; /* allow page to grow */
min-height: 100dvh; /* but always at least fill viewport */
}
.velxio-iframe {
max-height: none; /* iframe can grow taller than viewport */
height: auto;
min-height: 100dvh;
}
.pg-content {
min-height: 400px;
}
.pg-iframe {
min-height: 400px;
}
}
</style>