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"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import DeployFAB from '$components/DeployFAB.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); let velxioIframe = $state<HTMLIFrameElement | null>(null);
type DeployFABHandle = { setHex: (hex: string | null) => void }; type DeployFABHandle = { setHex: (hex: string | null) => void };
let fabComponent = $state<DeployFABHandle | null>(null); 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) { function handleMessage(e: MessageEvent) {
if (!e.data?.type) return; if (!e.data?.type) return;
@ -32,6 +43,14 @@
velxioIframe?.contentWindow?.postMessage({ type: 'elemes:stop_hardware_serial' }, window.location.origin); 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(() => { onMount(() => {
window.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage);
}); });
@ -42,56 +61,189 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Developer Playground — Velxio</title> <title>Developer Playground — Elemes</title>
</svelte:head> </svelte:head>
<div class="playground-page"> <div class="playground-page">
<iframe <!-- Tab bar -->
bind:this={velxioIframe} <div class="pg-tabs">
src={iframeSrc} <button class="pg-tab" class:active={activeTab === 'velxio'} onclick={() => activeTab = 'velxio'}>
title="Velxio Editor" Arduino
allow="bluetooth; clipboard-read; clipboard-write; fullscreen" </button>
class="velxio-iframe" <button class="pg-tab" class:active={activeTab === 'flowchart'} onclick={() => activeTab = 'flowchart'}>
allowfullscreen Flowchart
></iframe> </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>
<DeployFAB <!-- Content area -->
bind:this={fabComponent} <div class="pg-content">
{velxioIframe} {#if activeTab === 'velxio'}
onPushSerial={pushSerialToIframe} <iframe
onSerialStart={notifySerialStart} bind:this={velxioIframe}
onStopSerial={notifySerialStop} src={velxioSrc}
/> title="Velxio Editor"
allow="bluetooth; clipboard-read; clipboard-write; fullscreen"
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}
onPushSerial={pushSerialToIframe}
onSerialStart={notifySerialStart}
onStopSerial={notifySerialStop}
/>
{/if}
</div> </div>
<style> <style>
.playground-page { .playground-page {
width: 100%; width: 100%;
height: 100dvh; height: 100dvh;
overflow-y: auto; overflow: hidden;
overflow-x: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.velxio-iframe { /* ── Tab bar ── */
width: 100%; .pg-tabs {
flex: 1; display: flex;
min-height: 0; /* critical: allows flex child to shrink below content size */ align-items: flex-end;
border: none; gap: 2px;
display: block; padding: 0 8px;
touch-action: pan-y; /* allow vertical pan inside iframe */ 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) { @media (max-width: 768px) {
.playground-page { .playground-page {
overflow-y: auto; 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; height: auto;
min-height: 100dvh;
}
.pg-content {
min-height: 400px;
}
.pg-iframe {
min-height: 400px;
} }
} }
</style> </style>