Major changes: logic add to panel, local variables, panel histor, users management...
This commit is contained in:
+483
-140
@@ -1,17 +1,29 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useCallback, useRef } from 'preact/hooks';
|
||||
import type { Interface, Widget } from './lib/types';
|
||||
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 ContextualHelp from './ContextualHelp';
|
||||
import HelpModal from './HelpModal';
|
||||
import ZoomControl from './ZoomControl';
|
||||
import { useAuth, canWrite } from './lib/auth';
|
||||
|
||||
interface Props {
|
||||
initial: Interface | null;
|
||||
onDone: (iface: Interface) => void;
|
||||
onDone: (iface: Interface | null) => void;
|
||||
}
|
||||
|
||||
interface VersionMeta {
|
||||
version: number;
|
||||
name: string;
|
||||
tag?: string;
|
||||
current: boolean;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function blankInterface(): Interface {
|
||||
@@ -69,12 +81,14 @@ function distributeWidgets(widgets: Widget[], ids: string[], axis: 'h' | 'v'): 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 writable = canWrite(useAuth().level);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -86,8 +100,22 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
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');
|
||||
|
||||
function startResize(side: 'left' | 'right') {
|
||||
// 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;
|
||||
@@ -104,74 +132,189 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
};
|
||||
}
|
||||
function openHelp(section = 'edit') { setHelpSection(section); setShowHelp(true); }
|
||||
}, [leftW, rightW]);
|
||||
|
||||
// Undo / redo
|
||||
const undoStack = useRef<Widget[][]>([]);
|
||||
const redoStack = useRef<Widget[][]>([]);
|
||||
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 widgetsRef = useRef(iface.widgets || []);
|
||||
widgetsRef.current = iface.widgets || [];
|
||||
const layoutRef = useRef(iface.layout);
|
||||
layoutRef.current = iface.layout;
|
||||
|
||||
function pushUndo() {
|
||||
undoStack.current = [...undoStack.current.slice(-49), [...widgetsRef.current]];
|
||||
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]);
|
||||
|
||||
function undo() {
|
||||
const undo = useCallback(() => {
|
||||
if (undoStack.current.length === 0) return;
|
||||
const prev = undoStack.current[undoStack.current.length - 1];
|
||||
redoStack.current = [widgetsRef.current, ...redoStack.current];
|
||||
redoStack.current = [snapshot(), ...redoStack.current];
|
||||
undoStack.current = undoStack.current.slice(0, -1);
|
||||
setHistoryLen(undoStack.current.length);
|
||||
setIface(f => ({ ...f, widgets: prev }));
|
||||
setIface(f => ({ ...f, widgets: prev.widgets, layout: prev.layout }));
|
||||
setDirty(true);
|
||||
}
|
||||
}, [snapshot]);
|
||||
|
||||
function redo() {
|
||||
const redo = useCallback(() => {
|
||||
if (redoStack.current.length === 0) return;
|
||||
const next = redoStack.current[0];
|
||||
undoStack.current = [...undoStack.current, widgetsRef.current];
|
||||
undoStack.current = [...undoStack.current, snapshot()];
|
||||
redoStack.current = redoStack.current.slice(1);
|
||||
setHistoryLen(undoStack.current.length);
|
||||
setIface(f => ({ ...f, widgets: next }));
|
||||
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) }));
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
function doAlign(mode: string) {
|
||||
const aligned = alignWidgets(iface.widgets, selectedIds, mode);
|
||||
const doAlign = useCallback((mode: string) => {
|
||||
const aligned = alignWidgets(iface.widgets || [], selectedIds, mode);
|
||||
handleWidgetsChange(aligned);
|
||||
}
|
||||
}, [iface.widgets, selectedIds, handleWidgetsChange]);
|
||||
|
||||
function doDistribute(axis: 'h' | 'v') {
|
||||
const distributed = distributeWidgets(iface.widgets, selectedIds, axis);
|
||||
const doDistribute = useCallback((axis: 'h' | 'v') => {
|
||||
const distributed = distributeWidgets(iface.widgets || [], selectedIds, axis);
|
||||
handleWidgetsChange(distributed);
|
||||
}
|
||||
}, [iface.widgets, selectedIds, handleWidgetsChange]);
|
||||
|
||||
// ── Insert static widget ───────────────────────────────────────────────────
|
||||
|
||||
function insertWidget(type: string) {
|
||||
const insertWidget = useCallback((type: string) => {
|
||||
setShowInsertMenu(false);
|
||||
const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
|
||||
const newWidget: Widget = {
|
||||
@@ -182,22 +325,26 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
w: defW,
|
||||
h: defH,
|
||||
signals: [],
|
||||
options: type === 'textlabel' ? { label: 'Label' } : {},
|
||||
options:
|
||||
type === 'textlabel' ? { label: 'Label' } :
|
||||
type === 'button' ? { label: 'Button', mode: 'oneshot' } :
|
||||
{},
|
||||
};
|
||||
handleWidgetsChange([...iface.widgets, newWidget]);
|
||||
handleWidgetsChange([...(iface.widgets || []), newWidget]);
|
||||
setSelectedIds([newWidget.id]);
|
||||
}
|
||||
}, [iface.widgets, handleWidgetsChange]);
|
||||
|
||||
// ── Save / export / import ─────────────────────────────────────────────────
|
||||
|
||||
async function handleSave() {
|
||||
const handleSave = useCallback(async (saveTag = '') => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const xml = serializeInterface(iface);
|
||||
const url = iface.id
|
||||
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,
|
||||
@@ -210,15 +357,141 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
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]);
|
||||
|
||||
function handleExport() {
|
||||
// ── 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);
|
||||
@@ -227,9 +500,9 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
a.download = `${iface.name.replace(/[^a-z0-9]/gi, '_')}.xml`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}, [iface]);
|
||||
|
||||
function handleImport() {
|
||||
const handleImport = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.xml,application/xml,text/xml';
|
||||
@@ -248,78 +521,30 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
function handleLeave() {
|
||||
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);
|
||||
}
|
||||
|
||||
// Clipboard
|
||||
const clipboard = useRef<Widget[]>([]);
|
||||
|
||||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
|
||||
|
||||
function handleKeyDown(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; }
|
||||
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0) {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets: f.widgets.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 = iface.widgets
|
||||
.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: [...f.widgets, ...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(iface.widgets.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(iface.widgets.map(w =>
|
||||
selectedIds.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w
|
||||
));
|
||||
}
|
||||
}
|
||||
}, [dirty, onDone, iface]);
|
||||
|
||||
const selectedWidget = selectedIds.length === 1
|
||||
? iface.widgets.find(w => w.id === selectedIds[0]) ?? null
|
||||
? (iface.widgets || []).find(w => w.id === selectedIds[0]) ?? null
|
||||
: null;
|
||||
|
||||
const canUndo = historyLen > 0;
|
||||
@@ -327,7 +552,7 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
const multiSelected = selectedIds.length > 1;
|
||||
|
||||
return (
|
||||
<div class="edit-mode-layout" onKeyDown={handleKeyDown} tabIndex={-1}>
|
||||
<div class="edit-mode-layout" tabIndex={-1}>
|
||||
{/* ── Toolbar ── */}
|
||||
<header class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
@@ -362,17 +587,19 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
<span>Grid</span>
|
||||
</label>
|
||||
|
||||
{/* Insert static widget */}
|
||||
<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>
|
||||
{/* 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 && (
|
||||
@@ -402,7 +629,20 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
<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 toolbar-btn-primary" onClick={handleSave} disabled={saving}>
|
||||
<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>
|
||||
@@ -411,24 +651,127 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
|
||||
{/* ── Three-panel body ── */}
|
||||
<div class="edit-body">
|
||||
<SignalTree width={leftW} />
|
||||
<div class="panel-resize-handle" onMouseDown={startResize('left')} />
|
||||
<EditCanvas
|
||||
iface={iface}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={setSelectedIds}
|
||||
onChange={handleWidgetsChange}
|
||||
snapGrid={snapGrid}
|
||||
/>
|
||||
<div class="panel-resize-handle" onMouseDown={startResize('right')} />
|
||||
<PropertiesPane
|
||||
selected={selectedWidget}
|
||||
multiCount={multiSelected ? selectedIds.length : 0}
|
||||
iface={iface}
|
||||
onChange={handleWidgetChange}
|
||||
onIfaceChange={handleIfaceChange}
|
||||
width={rightW}
|
||||
/>
|
||||
{/* 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>
|
||||
<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' ? (
|
||||
<LogicEditor
|
||||
graph={iface.logic ?? { nodes: [], wires: [] }}
|
||||
onChange={handleLogicChange}
|
||||
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 && (
|
||||
|
||||
Reference in New Issue
Block a user