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'; import { withContainedWidgets } from './lib/containers'; import { VersionTree, DiffViewer } from './VersionHistory'; interface Props { initial: Interface | null; onDone: (iface: Interface | null) => void; } function blankInterface(): Interface { return { id: '', name: 'New Interface', version: 1, w: 1200, h: 800, widgets: [] }; } // Rename a widget id everywhere it is referenced so logic actions and plot // layouts keep pointing at the same widget after the user customizes its id. function renameInLayout(l: PlotLayout, oldId: string, newId: string): PlotLayout { if (l.type === 'leaf') return l.widget === oldId ? { ...l, widget: newId } : l; return { ...l, a: renameInLayout(l.a, oldId, newId), b: renameInLayout(l.b, oldId, newId) }; } function renameWidgetId(f: Interface, oldId: string, newId: string): Interface { const next: Interface = { ...f, widgets: (f.widgets || []).map(w => (w.id === oldId ? { ...w, id: newId } : w)), }; if (f.layout) next.layout = renameInLayout(f.layout, oldId, newId); if (f.logic) { next.logic = { ...f.logic, nodes: f.logic.nodes.map(n => n.kind === 'action.widget' && n.params.widget === oldId ? { ...n, params: { ...n.params, widget: newId } } : n), }; } return next; } // ── 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(); 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(); 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' }, { type: 'configselect', label: 'Config Selector' }, { type: 'container', label: 'Container Pane' }, { type: 'table', label: 'Table' }, ]; export default function EditMode({ initial, onDone }: Props) { const [iface, setIface] = useState(initial ?? blankInterface()); const me = useAuth(); const writable = canWrite(me.level); const [selectedIds, setSelectedIds] = useState([]); const [dirty, setDirty] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [snapGrid, setSnapGrid] = useState(10); const [showSnapGrid, setShowSnapGrid] = useState(true); const [showInsertMenu, setShowInsertMenu] = useState(false); const [showGroupMenu, setShowGroupMenu] = 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 [viewingVersion, setViewingVersion] = useState(null); const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null); const [verReload, setVerReload] = useState(0); 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([]); 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([]); const redoStack = useRef([]); 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 handleWidgetRename = useCallback((oldId: string, newId: string) => { pushUndo(); setIface(f => renameWidgetId(f, oldId, newId)); setSelectedIds(ids => ids.map(id => id === oldId ? newId : id)); 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; // Nudging a container also nudges the widgets sitting on it. const movers = withContainedWidgets(selectedIds, curWidgets); handleWidgetsChange(curWidgets.map(w => movers.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]); // ── Group selection into a container ──────────────────────────────────────── // Wrap the selected widgets in a new container sized to their bounding box. // The container is appended so it paints behind the grouped widgets (the // canvas always renders containers first); membership is geometric, so simply // enclosing the widgets places them inside. Extra top inset leaves room for // the title / tab bar above the grouped content. const groupIntoContainer = useCallback((variant: 'pane' | 'tabs') => { setShowGroupMenu(false); const widgets = iface.widgets || []; const sel = widgets.filter(w => selectedIds.includes(w.id)); if (sel.length < 2) return; const minX = Math.min(...sel.map(w => w.x)); const minY = Math.min(...sel.map(w => w.y)); const maxX = Math.max(...sel.map(w => w.x + w.w)); const maxY = Math.max(...sel.map(w => w.y + w.h)); const PAD = 12; const TOP = 36; // room for the title / tab bar above the grouped widgets const container: Widget = { id: genWidgetId(), type: 'container', x: minX - PAD, y: minY - TOP, w: (maxX - minX) + PAD * 2, h: (maxY - minY) + TOP + PAD, signals: [], options: variant === 'tabs' ? { variant: 'tabs', tabs: 'Tab 1,Tab 2', bg: 'true' } : { variant: 'pane', title: 'Group', collapsible: 'false', bg: 'true' }, }; // In tab mode, place the grouped widgets on the first tab so they show by default. const rest = variant === 'tabs' ? widgets.map(w => selectedIds.includes(w.id) ? { ...w, options: { ...w.options, tab: '0' } } : w) : widgets; handleWidgetsChange([...rest, container]); setSelectedIds([container.id]); }, [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' } : type === 'configselect' ? { label: 'Config', set: '', output: '', instances: '' } : type === 'container' ? { title: 'Pane', collapsible: 'false', bg: 'true' } : type === 'table' ? { columns: 'name,value,unit', header: 'true' } : {}, }; 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')); setViewingVersion(null); setVerReload(k => k + 1); } 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 toggleHistory = useCallback(() => { setShowHistory(s => { if (!s) setVerReload(k => k + 1); return !s; }); }, []); // 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([]); // Viewing a past revision is not itself a change — only subsequent edits // dirty the editor (saving then promotes it to a new revision). setDirty(false); setCanvasNonce(n => n + 1); undoStack.current = []; redoStack.current = []; setHistoryLen(0); setViewingVersion(version); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }, [iface.id, dirty]); // Reload the now-current interface content into the editor (after a promote, // which the VersionTree performs server-side). const reloadCurrentIface = useCallback(async () => { if (!iface.id) return; try { const cur = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`); if (!cur.ok) throw new Error(`Reload failed (${cur.status})`); const reloaded = parseInterface(await cur.text()); setIface(reloaded); setSelectedIds([]); setDirty(false); setCanvasNonce(n => n + 1); undoStack.current = []; redoStack.current = []; setHistoryLen(0); setViewingVersion(null); window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces')); setVerReload(k => k + 1); } 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 (
{/* ── Toolbar ── */}
uopi Edit handleIfaceChange({ ...iface, name: (e.target as HTMLInputElement).value })} /> {dirty && }
{/* Undo / redo */} {/* Snap to grid */} {/* Insert static widget — not applicable to split plot panels */} {iface.kind !== 'plot' && (
{showInsertMenu && (
setShowInsertMenu(false)}> {STATIC_WIDGET_TYPES.map(({ type, label }) => ( ))}
)}
)} {/* Align/distribute — only when ≥2 selected */} {selectedIds.length >= 2 && (
{selectedIds.length >= 3 && (
)}
{showGroupMenu && (
setShowGroupMenu(false)}>
)}
)} {error && Save error}
{/* ── Three-panel 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' && (
)}
{me.canEditLogic && ( )}
{centerTab === 'logic' && me.canEditLogic ? ( ) : iface.kind === 'plot' ? ( ) : ( )}
{centerTab !== 'logic' && (
)} {showHistory && (
Version History
setTag((e.target as HTMLInputElement).value)} />
{!iface.id ? (
Save the interface first to enable history.
) : ( window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'))} onPromoted={reloadCurrentIface} onCompare={(av, bv) => setDiff({ av, bv })} onError={setError} /> )}
)}
{diff && iface.id && ( setDiff(null)} /> )} {showHelp && ( setShowHelp(false)} /> )}
); }