Files
uopi/web/src/EditMode.tsx
T
2026-06-19 14:17:46 +02:00

785 lines
31 KiB
TypeScript

import { h, Fragment } from 'preact';
import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
import type { Interface, Widget, PlotLayout, StateVar, LogicGraph } from './lib/types';
import { serializeInterface, parseInterface } from './lib/xml';
import { initLocalState } from './lib/localstate';
import SignalTree from './SignalTree';
import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
import PlotPanelCanvas from './PlotPanelCanvas';
import LogicEditor from './LogicEditor';
import PropertiesPane from './PropertiesPane';
import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
import { useAuth, canWrite } from './lib/auth';
interface Props {
initial: Interface | null;
onDone: (iface: Interface | null) => void;
}
interface VersionMeta {
version: number;
name: string;
tag?: string;
current: boolean;
savedAt: string;
}
function blankInterface(): Interface {
return { id: '', name: 'New Interface', version: 1, w: 1200, h: 800, widgets: [] };
}
// ── Align / distribute helpers ──────────────────────────────────────────────
function alignWidgets(widgets: Widget[], ids: string[], mode: string): Widget[] {
const sel = widgets.filter(w => ids.includes(w.id));
if (sel.length < 2) return widgets;
const minX = Math.min(...sel.map(w => w.x));
const maxX = Math.max(...sel.map(w => w.x + w.w));
const minY = Math.min(...sel.map(w => w.y));
const maxY = Math.max(...sel.map(w => w.y + w.h));
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
return widgets.map(w => {
if (!ids.includes(w.id)) return w;
switch (mode) {
case 'left': return { ...w, x: minX };
case 'right': return { ...w, x: maxX - w.w };
case 'centerH': return { ...w, x: Math.round(cx - w.w / 2) };
case 'top': return { ...w, y: minY };
case 'bottom': return { ...w, y: maxY - w.h };
case 'centerV': return { ...w, y: Math.round(cy - w.h / 2) };
default: return w;
}
});
}
function distributeWidgets(widgets: Widget[], ids: string[], axis: 'h' | 'v'): Widget[] {
const sel = [...widgets.filter(w => ids.includes(w.id))];
if (sel.length < 3) return widgets;
if (axis === 'h') {
sel.sort((a, b) => a.x - b.x);
const totalW = sel.reduce((s, w) => s + w.w, 0);
const span = sel[sel.length - 1].x + sel[sel.length - 1].w - sel[0].x;
const gap = (span - totalW) / (sel.length - 1);
let cx = sel[0].x;
const positions = new Map<string, number>();
for (const w of sel) { positions.set(w.id, cx); cx += w.w + gap; }
return widgets.map(w => ids.includes(w.id) ? { ...w, x: Math.round(positions.get(w.id)!) } : w);
} else {
sel.sort((a, b) => a.y - b.y);
const totalH = sel.reduce((s, w) => s + w.h, 0);
const span = sel[sel.length - 1].y + sel[sel.length - 1].h - sel[0].y;
const gap = (span - totalH) / (sel.length - 1);
let cy = sel[0].y;
const positions = new Map<string, number>();
for (const w of sel) { positions.set(w.id, cy); cy += w.h + gap; }
return widgets.map(w => ids.includes(w.id) ? { ...w, y: Math.round(positions.get(w.id)!) } : w);
}
}
const STATIC_WIDGET_TYPES = [
{ type: 'textlabel', label: 'Text Label' },
{ type: 'button', label: 'Action Button' },
{ type: 'image', label: 'Image' },
{ type: 'link', label: 'Link' },
];
export default function EditMode({ initial, onDone }: Props) {
const [iface, setIface] = useState<Interface>(initial ?? blankInterface());
const me = useAuth();
const writable = canWrite(me.level);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [snapGrid, setSnapGrid] = useState(10);
const [showSnapGrid, setShowSnapGrid] = useState(true);
const [showInsertMenu, setShowInsertMenu] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [helpSection, setHelpSection] = useState('edit');
const [leftW, setLeftW] = useState(220);
const [rightW, setRightW] = useState(260);
// Center column tab: the drag-and-drop layout editor, or the panel-logic editor.
const [centerTab, setCenterTab] = useState<'layout' | 'logic'>('layout');
// History panel
const [showHistory, setShowHistory] = useState(false);
const [versions, setVersions] = useState<VersionMeta[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [tag, setTag] = useState('');
// Bumped whenever the editor content is replaced wholesale (version load /
// promote) to force a full canvas remount, so no stale widget state lingers.
const [canvasNonce, setCanvasNonce] = useState(0);
// Clipboard
const clipboard = useRef<Widget[]>([]);
const startResize = useCallback((side: 'left' | 'right') => {
return (e: MouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startW = side === 'left' ? leftW : rightW;
function onMove(mv: MouseEvent) {
const dx = mv.clientX - startX;
if (side === 'left') setLeftW(Math.max(140, startW + dx));
else setRightW(Math.max(180, startW - dx));
}
function onUp() {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
}, [leftW, rightW]);
const openHelp = useCallback((section = 'edit') => {
setHelpSection(section);
setShowHelp(true);
}, []);
// Undo / redo. Snapshots capture both widgets and (for plot panels) the split
// layout, so split/close/resize operations are undone atomically.
type Snapshot = { widgets: Widget[]; layout?: PlotLayout };
const undoStack = useRef<Snapshot[]>([]);
const redoStack = useRef<Snapshot[]>([]);
const [historyLen, setHistoryLen] = useState(0); // drives canUndo/canRedo display
const widgetsRef = useRef(iface.widgets || []);
widgetsRef.current = iface.widgets || [];
const layoutRef = useRef(iface.layout);
layoutRef.current = iface.layout;
const snapshot = useCallback((): Snapshot => ({
widgets: [...widgetsRef.current],
layout: layoutRef.current,
}), []);
const pushUndo = useCallback(() => {
undoStack.current = [...undoStack.current.slice(-49), snapshot()];
redoStack.current = [];
setHistoryLen(undoStack.current.length);
}, [snapshot]);
const undo = useCallback(() => {
if (undoStack.current.length === 0) return;
const prev = undoStack.current[undoStack.current.length - 1];
redoStack.current = [snapshot(), ...redoStack.current];
undoStack.current = undoStack.current.slice(0, -1);
setHistoryLen(undoStack.current.length);
setIface(f => ({ ...f, widgets: prev.widgets, layout: prev.layout }));
setDirty(true);
}, [snapshot]);
const redo = useCallback(() => {
if (redoStack.current.length === 0) return;
const next = redoStack.current[0];
undoStack.current = [...undoStack.current, snapshot()];
redoStack.current = redoStack.current.slice(1);
setHistoryLen(undoStack.current.length);
setIface(f => ({ ...f, widgets: next.widgets, layout: next.layout }));
setDirty(true);
}, [snapshot]);
const handleWidgetsChange = useCallback((widgets: Widget[]) => {
pushUndo();
setIface(f => ({ ...f, widgets }));
setDirty(true);
}, [pushUndo]);
// Combined widgets + layout change for plot-panel split/close/resize.
const handlePlotChange = useCallback((widgets: Widget[], layout: PlotLayout) => {
pushUndo();
setIface(f => ({ ...f, widgets, layout }));
setDirty(true);
}, [pushUndo]);
const handleWidgetChange = useCallback((updated: Widget) => {
pushUndo();
setIface(f => ({ ...f, widgets: (f.widgets || []).map(w => w.id === updated.id ? updated : w) }));
setDirty(true);
}, [pushUndo]);
const handleIfaceChange = useCallback((updated: Interface) => {
setIface(updated);
setDirty(true);
}, []);
const handleStateVarsChange = useCallback((statevars: StateVar[]) => {
setIface(f => ({ ...f, statevars }));
setDirty(true);
initLocalState(statevars);
}, []);
const handleLogicChange = useCallback((logic: LogicGraph) => {
setIface(f => ({ ...f, logic }));
setDirty(true);
}, []);
// Instantiate local state variables when the edited panel loads/changes so
// they can be previewed live in the canvas just like real signals.
useEffect(() => {
initLocalState(iface.statevars);
}, [iface.id]);
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
const handleKeyDown = useCallback((e: KeyboardEvent) => {
const target = e.target as Element;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
if (e.key === '?') { openHelp('edit'); return; }
// The logic editor manages its own undo/redo/clipboard/delete shortcuts.
if (centerTab === 'logic') return;
// Plot panels use split/close controls instead of free-form widget editing;
// only undo/redo apply here.
if (iface.kind === 'plot') {
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
return;
}
const curWidgets = widgetsRef.current;
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0) {
pushUndo();
setIface(f => ({ ...f, widgets: curWidgets.filter(w => !selectedIds.includes(w.id)) }));
setSelectedIds([]);
setDirty(true);
return;
}
// Copy
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && selectedIds.length > 0) {
e.preventDefault();
clipboard.current = curWidgets
.filter(w => selectedIds.includes(w.id))
.map(w => ({ ...w }));
return;
}
// Paste
if ((e.ctrlKey || e.metaKey) && e.key === 'v' && clipboard.current.length > 0) {
e.preventDefault();
pushUndo();
const offset = 20;
const pasted = clipboard.current.map(w => ({
...w,
id: genWidgetId(),
x: w.x + offset,
y: w.y + offset,
}));
setIface(f => ({ ...f, widgets: [...curWidgets, ...pasted] }));
setSelectedIds(pasted.map(w => w.id));
setDirty(true);
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
e.preventDefault();
setSelectedIds(curWidgets.map(w => w.id));
return;
}
// Arrow nudge
if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key) && selectedIds.length > 0) {
e.preventDefault();
const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1);
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
handleWidgetsChange(curWidgets.map(w =>
selectedIds.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w
));
}
}, [selectedIds, snapGrid, undo, redo, handleWidgetsChange, openHelp, pushUndo, iface.kind, centerTab]);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
// ── Align / distribute ─────────────────────────────────────────────────────
const doAlign = useCallback((mode: string) => {
const aligned = alignWidgets(iface.widgets || [], selectedIds, mode);
handleWidgetsChange(aligned);
}, [iface.widgets, selectedIds, handleWidgetsChange]);
const doDistribute = useCallback((axis: 'h' | 'v') => {
const distributed = distributeWidgets(iface.widgets || [], selectedIds, axis);
handleWidgetsChange(distributed);
}, [iface.widgets, selectedIds, handleWidgetsChange]);
// ── Insert static widget ───────────────────────────────────────────────────
const insertWidget = useCallback((type: string) => {
setShowInsertMenu(false);
const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
const newWidget: Widget = {
id: genWidgetId(),
type,
x: 40,
y: 40,
w: defW,
h: defH,
signals: [],
options:
type === 'textlabel' ? { label: 'Label' } :
type === 'button' ? { label: 'Button', mode: 'oneshot' } :
{},
};
handleWidgetsChange([...(iface.widgets || []), newWidget]);
setSelectedIds([newWidget.id]);
}, [iface.widgets, handleWidgetsChange]);
// ── Save / export / import ─────────────────────────────────────────────────
const handleSave = useCallback(async (saveTag = '') => {
setSaving(true);
setError(null);
try {
const xml = serializeInterface(iface);
const base = iface.id
? `/api/v1/interfaces/${encodeURIComponent(iface.id)}`
: '/api/v1/interfaces';
const url = saveTag ? `${base}?tag=${encodeURIComponent(saveTag)}` : base;
const method = iface.id ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/xml' },
body: xml,
});
if (!res.ok) throw new Error(`Save failed (${res.status}): ${await res.text()}`);
if (method === 'POST') {
const json = await res.json();
setIface(f => ({ ...f, id: json.id }));
}
setDirty(false);
setTag('');
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
if (showHistory) loadVersions();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
// loadVersions is stable (declared below); intentionally omitted from deps.
}, [iface, showHistory]);
// ── Version history ────────────────────────────────────────────────────────
const loadVersions = useCallback(async () => {
if (!iface.id) { setVersions([]); return; }
setHistoryLoading(true);
try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions`);
if (!res.ok) throw new Error(`Load versions failed (${res.status})`);
setVersions(await res.json());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setHistoryLoading(false);
}
}, [iface.id]);
const toggleHistory = useCallback(() => {
setShowHistory(s => {
if (!s) loadVersions();
return !s;
});
}, [loadVersions]);
// Load a past revision into the editor non-destructively: the current id is
// preserved, so saving the restored content creates a new revision on top.
const loadVersion = useCallback(async (version: number) => {
if (!iface.id) return;
if (dirty && !confirm('You have unsaved changes. Load this version anyway?')) return;
try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}`);
if (!res.ok) throw new Error(`Load version failed (${res.status})`);
const restored = parseInterface(await res.text());
restored.id = iface.id; // keep editing the same interface
setIface(restored);
setSelectedIds([]);
setDirty(true);
setCanvasNonce(n => n + 1);
undoStack.current = [];
redoStack.current = [];
setHistoryLen(0);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [iface.id, dirty]);
// Edit (or clear) the label of an existing revision in place.
const editVersionTag = useCallback(async (version: number, currentTag: string) => {
if (!iface.id) return;
const next = prompt('Label for this version (leave empty to clear):', currentTag ?? '');
if (next === null) return;
try {
const res = await fetch(
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/tag?tag=${encodeURIComponent(next)}`,
{ method: 'PUT' },
);
if (!res.ok) throw new Error(`Tag update failed (${res.status})`);
loadVersions();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [iface.id, loadVersions]);
// Make a past revision the current one (saved as a new revision on top).
const promoteVersion = useCallback(async (version: number) => {
if (!iface.id) return;
if (dirty && !confirm('You have unsaved changes that will be discarded. Set this version as current?')) return;
try {
const res = await fetch(
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/promote`,
{ method: 'POST' },
);
if (!res.ok) throw new Error(`Promote failed (${res.status})`);
// Reload the now-current interface content into the editor.
const cur = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
const reloaded = parseInterface(await cur.text());
setIface(reloaded);
setSelectedIds([]);
setDirty(false);
setCanvasNonce(n => n + 1);
undoStack.current = [];
redoStack.current = [];
setHistoryLen(0);
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
loadVersions();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [iface.id, dirty, loadVersions]);
// Fork a revision into a brand-new interface.
const forkVersion = useCallback(async (version: number) => {
if (!iface.id) return;
try {
const res = await fetch(
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/fork`,
{ method: 'POST' },
);
if (!res.ok) throw new Error(`Fork failed (${res.status})`);
const json = await res.json();
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
alert(`Forked into new interface "${json.id}". Open it from the interface list.`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [iface.id]);
// ── Auto-snapshot: every 5 minutes, if there are unsaved changes on an
// already-persisted interface, save a new revision automatically. ──────────
const saveRef = useRef(handleSave);
saveRef.current = handleSave;
const autoSaveState = useRef({ dirty, id: iface.id, saving });
autoSaveState.current = { dirty, id: iface.id, saving };
useEffect(() => {
const interval = setInterval(() => {
const s = autoSaveState.current;
if (s.dirty && s.id && !s.saving) {
saveRef.current('auto');
}
}, 5 * 60 * 1000);
return () => clearInterval(interval);
}, []);
const handleExport = useCallback(() => {
const xml = serializeInterface(iface);
const blob = new Blob([xml], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${iface.name.replace(/[^a-z0-9]/gi, '_')}.xml`;
a.click();
URL.revokeObjectURL(url);
}, [iface]);
const handleImport = useCallback(() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.xml,application/xml,text/xml';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
try {
setIface(parseInterface(await file.text()));
setSelectedIds([]);
setDirty(true);
undoStack.current = [];
redoStack.current = [];
setHistoryLen(0);
} catch (err) {
setError(`Import failed: ${err instanceof Error ? err.message : err}`);
}
};
input.click();
}, []);
const handleLeave = useCallback(async () => {
if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return;
// Brand-new panel that was never saved: nothing to show — return to the
// previously open panel (signalled by passing null).
if (!iface.id) { onDone(null); return; }
// Existing panel closed with unsaved edits: reopen the last saved (active)
// version rather than carrying the discarded in-memory changes into view.
if (dirty) {
try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
onDone(parseInterface(await res.text()));
} catch {
onDone(null);
}
return;
}
onDone(iface);
}, [dirty, onDone, iface]);
const selectedWidget = selectedIds.length === 1
? (iface.widgets || []).find(w => w.id === selectedIds[0]) ?? null
: null;
const canUndo = historyLen > 0;
const canRedo = redoStack.current.length > 0;
const multiSelected = selectedIds.length > 1;
return (
<div class="edit-mode-layout" tabIndex={-1}>
{/* ── Toolbar ── */}
<header class="toolbar">
<div class="toolbar-left">
<span class="app-name">uopi</span>
<span class="edit-badge">Edit</span>
<input
class="iface-name-input"
value={iface.name}
onInput={(e) => handleIfaceChange({ ...iface, name: (e.target as HTMLInputElement).value })}
/>
{dirty && <span class="dirty-dot" title="Unsaved changes"></span>}
</div>
<div class="toolbar-center edit-toolbar-tools">
{/* Undo / redo */}
<button class="toolbar-btn" onClick={undo} disabled={!canUndo} title="Undo (Ctrl+Z)"></button>
<button class="toolbar-btn" onClick={redo} disabled={!canRedo} title="Redo (Ctrl+Y)"></button>
<span class="toolbar-sep" />
{/* Snap to grid */}
<label class="snap-toggle" title="Snap to grid">
<input
type="checkbox"
checked={showSnapGrid}
onChange={(e) => {
const on = (e.target as HTMLInputElement).checked;
setShowSnapGrid(on);
setSnapGrid(on ? 10 : 0);
}}
/>
<span>Grid</span>
</label>
{/* Insert static widget — not applicable to split plot panels */}
{iface.kind !== 'plot' && (
<div class="toolbar-dropdown">
<button class="toolbar-btn" onClick={() => setShowInsertMenu(m => !m)}>+ Insert </button>
{showInsertMenu && (
<div class="toolbar-dropdown-menu" onClick={() => setShowInsertMenu(false)}>
{STATIC_WIDGET_TYPES.map(({ type, label }) => (
<button key={type} class="ctx-item" onClick={() => insertWidget(type)}>{label}</button>
))}
</div>
)}
</div>
)}
{/* Align/distribute — only when ≥2 selected */}
{selectedIds.length >= 2 && (
<div class="align-toolbar">
<span class="toolbar-sep" />
<button class="toolbar-btn icon-only" onClick={() => doAlign('left')} title="Align left">⬜◻◻</button>
<button class="toolbar-btn icon-only" onClick={() => doAlign('centerH')} title="Align center H">◻⬜◻</button>
<button class="toolbar-btn icon-only" onClick={() => doAlign('right')} title="Align right">◻◻⬜</button>
<button class="toolbar-btn icon-only" onClick={() => doAlign('top')} title="Align top"></button>
<button class="toolbar-btn icon-only" onClick={() => doAlign('centerV')} title="Align center V"></button>
<button class="toolbar-btn icon-only" onClick={() => doAlign('bottom')} title="Align bottom"></button>
{selectedIds.length >= 3 && (
<div>
<button class="toolbar-btn icon-only" onClick={() => doDistribute('h')} title="Distribute H"></button>
<button class="toolbar-btn icon-only" onClick={() => doDistribute('v')} title="Distribute V">↕↕</button>
</div>
)}
</div>
)}
{error && <span class="parse-error" title={error}>Save error</span>}
</div>
<div class="toolbar-right">
<ZoomControl />
<button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button>
<button class="toolbar-btn" onClick={handleImport}>Import</button>
<button class="toolbar-btn" onClick={handleExport}>Export</button>
<button
class={`toolbar-btn${showHistory ? ' toolbar-btn-active' : ''}`}
onClick={toggleHistory}
disabled={!iface.id}
title={iface.id ? 'Version history' : 'Save the interface first to enable history'}
>
🕘 History
</button>
<button
class="toolbar-btn toolbar-btn-primary"
onClick={() => handleSave(tag)}
disabled={saving || !writable}
title={writable ? '' : 'You have read-only access'}
>
{saving ? 'Saving…' : 'Save'}
</button>
<button class="toolbar-btn" onClick={handleLeave}> Close</button>
</div>
</header>
{/* ── Three-panel body ── */}
<div class="edit-body">
{/* The logic tab is a self-contained flow editor with its own palette and
inspector, so the layout-only side panes are hidden while it is open. */}
{centerTab !== 'logic' && (
<Fragment>
<SignalTree
width={leftW}
panelId={iface.id}
statevars={iface.statevars}
onStateVarsChange={handleStateVarsChange}
/>
<div class="panel-resize-handle" onMouseDown={startResize('left')} />
</Fragment>
)}
<div class="edit-center">
<div class="center-tabs">
<button
class={`center-tab${centerTab === 'layout' ? ' center-tab-active' : ''}`}
onClick={() => setCenterTab('layout')}
>Layout</button>
{me.canEditLogic && (
<button
class={`center-tab${centerTab === 'logic' ? ' center-tab-active' : ''}`}
onClick={() => setCenterTab('logic')}
>Logic{iface.logic && iface.logic.nodes.length > 0 ? ` (${iface.logic.nodes.length})` : ''}</button>
)}
</div>
{centerTab === 'logic' && me.canEditLogic ? (
<LogicEditor
graph={iface.logic ?? { nodes: [], wires: [] }}
onChange={handleLogicChange}
widgets={iface.widgets}
statevars={iface.statevars}
onStateVarsChange={handleStateVarsChange}
/>
) : iface.kind === 'plot' ? (
<PlotPanelCanvas
key={canvasNonce}
iface={iface}
selectedIds={selectedIds}
onSelect={setSelectedIds}
onChange={handlePlotChange}
/>
) : (
<EditCanvas
key={canvasNonce}
iface={iface}
selectedIds={selectedIds}
onSelect={setSelectedIds}
onChange={handleWidgetsChange}
snapGrid={snapGrid}
/>
)}
</div>
{centerTab !== 'logic' && (
<Fragment>
<div class="panel-resize-handle" onMouseDown={startResize('right')} />
<PropertiesPane
selected={selectedWidget}
multiCount={multiSelected ? selectedIds.length : 0}
iface={iface}
onChange={handleWidgetChange}
onIfaceChange={handleIfaceChange}
width={rightW}
/>
</Fragment>
)}
{showHistory && (
<div class="history-pane">
<div class="history-pane-header">
<span>Version History</span>
<button class="icon-btn" onClick={() => setShowHistory(false)} title="Close"></button>
</div>
<div class="history-save-row">
<input
class="history-tag-input"
placeholder="Optional label…"
value={tag}
onInput={(e) => setTag((e.target as HTMLInputElement).value)}
/>
<button class="toolbar-btn" onClick={() => handleSave(tag)} disabled={saving || !writable}>
Save snapshot
</button>
</div>
<div class="history-list">
{historyLoading && <div class="history-empty">Loading</div>}
{!historyLoading && versions.length === 0 && (
<div class="history-empty">No saved versions yet.</div>
)}
{!historyLoading && versions.map(v => (
<div
key={v.version}
class={`history-item${v.current ? ' history-item-current' : ''}`}
>
<div class="history-item-main">
<span class="history-version">v{v.version}</span>
{v.tag && <span class="history-tag">{v.tag}</span>}
{v.current && <span class="history-current-badge">current</span>}
</div>
<div class="history-item-meta">
{new Date(v.savedAt).toLocaleString()}
</div>
<div class="history-item-actions">
{!v.current && (
<button class="history-action-btn" onClick={() => loadVersion(v.version)} title="Load this version into the editor (does not change history)">
Load
</button>
)}
{!v.current && (
<button class="history-action-btn" onClick={() => promoteVersion(v.version)} title="Make this version the current one">
Set current
</button>
)}
<button class="history-action-btn" onClick={() => forkVersion(v.version)} title="Create a new interface from this version">
Fork
</button>
<button class="history-action-btn" onClick={() => editVersionTag(v.version, v.tag ?? '')} title="Edit this version's label">
Label
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
{showHelp && (
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
)}
</div>
);
}