785 lines
31 KiB
TypeScript
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>
|
|
);
|
|
}
|