velxio/frontend/src/components/customChips/CustomChipDialog.tsx

420 lines
15 KiB
TypeScript

/**
* CustomChipDialog — modal for editing/compiling a Velxio custom chip.
*
* Two tabs:
* - "Examples": gallery of pre-built chips. Click → loads the source.
* - "Editor": Monaco editors for chip.c and chip.json + Compile button +
* stdout/stderr panel + Save button.
*
* The Save action persists the compiled WASM (base64) into the component's
* `properties` so the chip is fully self-contained inside the project.
*/
import { useState, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import Editor from '@monaco-editor/react';
import { CHIP_EXAMPLES, BLANK_CHIP, type ChipExample } from './chipExamples';
import { compileChip, type ChipCompileResult } from '../../services/chipCompileService';
export interface CustomChipDialogProps {
/** Initial chip data (from component.properties). */
initial: {
chipName: string;
sourceC: string;
chipJson: string;
wasmBase64: string;
attrs?: Record<string, number>;
};
onClose: () => void;
onSave: (data: {
chipName: string;
sourceC: string;
chipJson: string;
wasmBase64: string;
attrs: Record<string, number>;
}) => void;
}
interface AttributeDef {
name: string;
label?: string;
type?: 'int' | 'float' | 'number';
default?: number;
min?: number;
max?: number;
step?: number;
}
function parseAttributes(chipJson: string): AttributeDef[] {
try {
const obj = JSON.parse(chipJson);
if (Array.isArray(obj.attributes)) {
return obj.attributes.map((a: any): AttributeDef => ({
name: String(a.name ?? ''),
label: typeof a.label === 'string' ? a.label : undefined,
type: a.type === 'float' || a.type === 'int' ? a.type : 'number',
default: typeof a.default === 'number' ? a.default : undefined,
min: typeof a.min === 'number' ? a.min : undefined,
max: typeof a.max === 'number' ? a.max : undefined,
step: typeof a.step === 'number' ? a.step : undefined,
})).filter((a: AttributeDef) => a.name);
}
} catch { /* ignore */ }
return [];
}
type Tab = 'examples' | 'editor';
export const CustomChipDialog = ({ initial, onClose, onSave }: CustomChipDialogProps) => {
const { t } = useTranslation();
// If the chip already has source code, skip the examples tab on open.
const [tab, setTab] = useState<Tab>(initial.sourceC.trim() ? 'editor' : 'examples');
const [chipName, setChipName] = useState(initial.chipName || 'My Chip');
const [sourceC, setSourceC] = useState(initial.sourceC || BLANK_CHIP.sourceC);
const [chipJson, setChipJson] = useState(initial.chipJson || BLANK_CHIP.chipJson);
const [wasmBase64, setWasmBase64] = useState(initial.wasmBase64 || '');
const [compiling, setCompiling] = useState(false);
const [result, setResult] = useState<ChipCompileResult | null>(null);
const [attrs, setAttrs] = useState<Record<string, number>>(initial.attrs ?? {});
const attrDefs = useMemo(() => parseAttributes(chipJson), [chipJson]);
// When attribute defs change (e.g. user loaded a new example), seed defaults
// for any newly-introduced attribute that doesn't have a saved value yet.
useEffect(() => {
setAttrs((prev) => {
const next = { ...prev };
let changed = false;
for (const a of attrDefs) {
if (!(a.name in next) && a.default !== undefined) {
next[a.name] = a.default;
changed = true;
}
}
return changed ? next : prev;
});
}, [attrDefs]);
// Categories shown as section headers in the gallery.
const grouped = useMemo(() => {
const m = new Map<string, ChipExample[]>();
for (const e of CHIP_EXAMPLES) {
if (!m.has(e.category)) m.set(e.category, []);
m.get(e.category)!.push(e);
}
return Array.from(m.entries());
}, []);
// Pull chipName out of chip.json whenever it changes — keeps a single source
// of truth without forcing the user to edit two fields.
useEffect(() => {
try {
const obj = JSON.parse(chipJson);
if (obj && typeof obj.name === 'string' && obj.name.trim()) {
setChipName(obj.name);
}
} catch { /* user is mid-edit, ignore */ }
}, [chipJson]);
const loadExample = (e: ChipExample) => {
setSourceC(e.sourceC);
setChipJson(e.chipJson);
setWasmBase64(''); // force re-compile so the binary matches the loaded source
setResult(null);
setTab('editor');
};
const doCompile = async () => {
setCompiling(true);
setResult(null);
try {
const r = await compileChip(sourceC, chipJson);
setResult(r);
if (r.success && r.wasm_base64) setWasmBase64(r.wasm_base64);
} finally {
setCompiling(false);
}
};
const canSave = wasmBase64.length > 0;
const doSave = () => {
if (!canSave) return;
onSave({ chipName, sourceC, chipJson, wasmBase64, attrs });
};
return (
<div style={overlayStyle} onClick={onClose}>
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
<div style={headerStyle}>
<strong style={{ flex: 1 }}>{t('editor.customChip.title', { chipName })}</strong>
<button style={closeBtn} onClick={onClose}></button>
</div>
<div style={tabsRowStyle}>
<button
style={{ ...tabBtn, ...(tab === 'examples' ? tabActive : null) }}
onClick={() => setTab('examples')}
>
{t('editor.customChip.tabExamples')}
</button>
<button
style={{ ...tabBtn, ...(tab === 'editor' ? tabActive : null) }}
onClick={() => setTab('editor')}
>
{t('editor.customChip.tabEditor')}
</button>
</div>
<div style={bodyStyle}>
{tab === 'examples' && (
<div style={{ overflow: 'auto', padding: 12 }}>
<button style={blankBtn} onClick={() => loadExample(BLANK_CHIP)}>
+ Start from blank
</button>
{grouped.map(([cat, list]) => (
<div key={cat} style={{ marginTop: 18 }}>
<div style={categoryStyle}>{cat.toUpperCase()}</div>
<div style={gridStyle}>
{list.map((ex) => (
<button
key={ex.id}
style={cardBtn}
onClick={() => loadExample(ex)}
title={ex.description}
>
<div style={cardName}>{ex.name}</div>
<div style={cardDesc}>{ex.description}</div>
</button>
))}
</div>
</div>
))}
</div>
)}
{tab === 'editor' && (
<div style={editorLayout}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div style={editorLabel}>chip.c</div>
<div style={{ flex: 1 }}>
<Editor
height="100%"
language="cpp"
theme="vs-dark"
value={sourceC}
onChange={(v) => { setSourceC(v ?? ''); setWasmBase64(''); }}
options={{
minimap: { enabled: false },
fontSize: 12,
automaticLayout: true,
scrollBeyondLastLine: false,
}}
/>
</div>
</div>
<div style={{ width: 360, display: 'flex', flexDirection: 'column' }}>
<div style={editorLabel}>chip.json</div>
<div style={{ flex: 1, minHeight: 200 }}>
<Editor
height="100%"
language="json"
theme="vs-dark"
value={chipJson}
onChange={(v) => setChipJson(v ?? '')}
options={{
minimap: { enabled: false },
fontSize: 12,
automaticLayout: true,
}}
/>
</div>
{attrDefs.length > 0 && (
<div style={attrPanelStyle}>
<div style={attrPanelHeader}>{t('editor.customChip.attributes')}</div>
{attrDefs.map((a) => (
<AttrRow
key={a.name}
def={a}
value={attrs[a.name] ?? a.default ?? 0}
onChange={(v) => setAttrs((prev) => ({ ...prev, [a.name]: v }))}
/>
))}
</div>
)}
</div>
</div>
)}
</div>
{tab === 'editor' && (
<div style={resultPanelStyle}>
{compiling && <span style={{ color: '#ffa500' }}>{t('editor.customChip.compiling')}</span>}
{!compiling && result && result.success && (
<span style={{ color: '#22c55e' }}>
{t('editor.customChip.compiledOk', { kb: (result.byte_size / 1024).toFixed(1) })}
</span>
)}
{!compiling && result && !result.success && (
<pre style={errorPreStyle}>{result.error || t('editor.customChip.compileFailed')}{'\n'}{result.stderr}</pre>
)}
</div>
)}
<div style={footerStyle}>
{tab === 'editor' && (
<button style={compileBtn} onClick={doCompile} disabled={compiling}>
{compiling ? t('editor.customChip.compiling') : t('editor.customChip.compile')}
</button>
)}
<div style={{ flex: 1 }} />
<button style={cancelBtn} onClick={onClose}>{t('editor.customChip.cancel')}</button>
<button style={canSave ? saveBtn : saveBtnDisabled} disabled={!canSave} onClick={doSave}>
{canSave ? t('editor.customChip.savePlace') : t('editor.customChip.compileFirst')}
</button>
</div>
</div>
</div>
);
};
// ── Inline styles (matches the visual language of other Velxio modals) ──
const overlayStyle: React.CSSProperties = {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)',
zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center',
};
const dialogStyle: React.CSSProperties = {
width: '90vw', height: '85vh', maxWidth: 1280,
background: '#1f1f1f', color: '#e0e0e0', borderRadius: 6,
display: 'flex', flexDirection: 'column',
boxShadow: '0 12px 36px rgba(0,0,0,0.5)',
};
const headerStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', padding: '10px 14px',
borderBottom: '1px solid #333', background: '#252526',
};
const closeBtn: React.CSSProperties = {
background: 'transparent', border: 'none', color: '#999',
fontSize: 18, cursor: 'pointer', padding: '2px 8px',
};
const tabsRowStyle: React.CSSProperties = {
display: 'flex', gap: 4, padding: '6px 12px 0',
borderBottom: '1px solid #333', background: '#252526',
};
const tabBtn: React.CSSProperties = {
padding: '6px 14px', background: 'transparent', color: '#999',
border: 'none', borderBottom: '2px solid transparent',
cursor: 'pointer', fontSize: 13,
};
const tabActive: React.CSSProperties = { color: '#e0e0e0', borderBottom: '2px solid #007acc' };
const bodyStyle: React.CSSProperties = { flex: 1, overflow: 'hidden', display: 'flex' };
const editorLayout: React.CSSProperties = {
display: 'flex', flex: 1, gap: 8, padding: 8, overflow: 'hidden',
};
const editorLabel: React.CSSProperties = {
padding: '4px 8px', fontSize: 11, color: '#888',
background: '#252526', borderTopLeftRadius: 4, borderTopRightRadius: 4,
};
const resultPanelStyle: React.CSSProperties = {
borderTop: '1px solid #333', padding: '6px 14px',
fontSize: 12, fontFamily: 'monospace', maxHeight: 120, overflow: 'auto',
};
const errorPreStyle: React.CSSProperties = {
margin: 0, color: '#f87171', whiteSpace: 'pre-wrap', fontSize: 11,
};
const footerStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px',
borderTop: '1px solid #333', background: '#252526',
};
const compileBtn: React.CSSProperties = {
padding: '6px 14px', background: '#007acc', color: 'white',
border: 'none', borderRadius: 3, cursor: 'pointer', fontSize: 13,
};
const cancelBtn: React.CSSProperties = {
padding: '6px 14px', background: '#3a3a3a', color: '#e0e0e0',
border: 'none', borderRadius: 3, cursor: 'pointer', fontSize: 13,
};
const saveBtn: React.CSSProperties = {
padding: '6px 14px', background: '#22c55e', color: 'white',
border: 'none', borderRadius: 3, cursor: 'pointer', fontSize: 13,
};
const saveBtnDisabled: React.CSSProperties = { ...saveBtn, background: '#3a3a3a', cursor: 'not-allowed' };
const blankBtn: React.CSSProperties = {
padding: '8px 14px', background: '#2d2d30', color: '#e0e0e0',
border: '1px dashed #555', borderRadius: 4, cursor: 'pointer',
width: '100%', textAlign: 'left', fontSize: 13,
};
const categoryStyle: React.CSSProperties = {
fontSize: 10, color: '#888', letterSpacing: 1, marginBottom: 6,
};
const gridStyle: React.CSSProperties = {
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 8,
};
const cardBtn: React.CSSProperties = {
padding: '10px 12px', background: '#2d2d30', color: '#e0e0e0',
border: '1px solid #3a3a3a', borderRadius: 4, cursor: 'pointer',
textAlign: 'left',
};
const cardName: React.CSSProperties = { fontSize: 13, fontWeight: 'bold', marginBottom: 4 };
const cardDesc: React.CSSProperties = { fontSize: 11, color: '#999', lineHeight: 1.4 };
const attrPanelStyle: React.CSSProperties = {
marginTop: 8, padding: '8px 10px', background: '#252526',
borderRadius: 4, maxHeight: 220, overflow: 'auto',
};
const attrPanelHeader: React.CSSProperties = {
fontSize: 10, color: '#888', letterSpacing: 1, marginBottom: 6, textTransform: 'uppercase',
};
function AttrRow({
def,
value,
onChange,
}: {
def: AttributeDef;
value: number;
onChange: (v: number) => void;
}) {
const isInt = def.type === 'int';
const min = def.min;
const max = def.max;
const step = def.step ?? (isInt ? 1 : 0.01);
const showSlider = min !== undefined && max !== undefined;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<label style={{ flex: '0 0 96px', fontSize: 11, color: '#bbb' }}>{def.label || def.name}</label>
{showSlider && (
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => {
const v = parseFloat(e.target.value);
onChange(isInt ? Math.round(v) : v);
}}
style={{ flex: 1 }}
/>
)}
<input
type="number"
min={min}
max={max}
step={step}
value={Number.isFinite(value) ? value : 0}
onChange={(e) => {
const v = parseFloat(e.target.value);
if (!Number.isFinite(v)) return;
onChange(isInt ? Math.round(v) : v);
}}
style={{
width: 76, padding: '2px 4px', fontSize: 11,
background: '#1f1f1f', color: '#e0e0e0',
border: '1px solid #444', borderRadius: 2,
}}
/>
</div>
);
}