This commit is contained in:
Martino Ferrari
2026-04-25 23:10:51 +02:00
parent 986f6cd6d8
commit 91b42027c9
6 changed files with 525 additions and 136 deletions
+234 -41
View File
@@ -1,9 +1,9 @@
import { h } from 'preact';
import { useState, useCallback } from 'preact/hooks';
import { useState, useCallback, useRef } from 'preact/hooks';
import type { Interface, Widget } from './lib/types';
import { serializeInterface, parseInterface } from './lib/xml';
import SignalTree from './SignalTree';
import EditCanvas from './EditCanvas';
import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
import PropertiesPane from './PropertiesPane';
interface Props {
@@ -11,35 +11,116 @@ interface Props {
onDone: () => void;
}
let _newId = Date.now();
function newIfaceId() { return `iface-${_newId++}`; }
function blankInterface(): Interface {
return {
id: newIfaceId(),
name: 'New Interface',
version: 1,
w: 1200,
h: 800,
widgets: [],
};
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: 'image', label: 'Image' },
{ type: 'link', label: 'Link' },
];
export default function EditMode({ initial, onDone }: Props) {
const [iface, setIface] = useState<Interface>(initial ?? blankInterface());
const [selectedId, setSelectedId] = useState<string | null>(null);
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 selectedWidget = iface.widgets.find(w => w.id === selectedId) ?? null;
// Undo / redo
const undoStack = useRef<Widget[][]>([]);
const redoStack = useRef<Widget[][]>([]);
const [historyLen, setHistoryLen] = useState(0); // drives canUndo/canRedo display
const widgetsRef = useRef(iface.widgets);
widgetsRef.current = iface.widgets;
function pushUndo() {
undoStack.current = [...undoStack.current.slice(-49), [...widgetsRef.current]];
redoStack.current = [];
setHistoryLen(undoStack.current.length);
}
function undo() {
if (undoStack.current.length === 0) return;
const prev = undoStack.current[undoStack.current.length - 1];
redoStack.current = [widgetsRef.current, ...redoStack.current];
undoStack.current = undoStack.current.slice(0, -1);
setHistoryLen(undoStack.current.length);
setIface(f => ({ ...f, widgets: prev }));
setDirty(true);
}
function redo() {
if (redoStack.current.length === 0) return;
const next = redoStack.current[0];
undoStack.current = [...undoStack.current, widgetsRef.current];
redoStack.current = redoStack.current.slice(1);
setHistoryLen(undoStack.current.length);
setIface(f => ({ ...f, widgets: next }));
setDirty(true);
}
const handleWidgetsChange = useCallback((widgets: Widget[]) => {
pushUndo();
setIface(f => ({ ...f, widgets }));
setDirty(true);
}, []);
const handleWidgetChange = useCallback((updated: Widget) => {
pushUndo();
setIface(f => ({ ...f, widgets: f.widgets.map(w => w.id === updated.id ? updated : w) }));
setDirty(true);
}, []);
@@ -49,6 +130,39 @@ export default function EditMode({ initial, onDone }: Props) {
setDirty(true);
}, []);
// ── Align / distribute ─────────────────────────────────────────────────────
function doAlign(mode: string) {
const aligned = alignWidgets(iface.widgets, selectedIds, mode);
handleWidgetsChange(aligned);
}
function doDistribute(axis: 'h' | 'v') {
const distributed = distributeWidgets(iface.widgets, selectedIds, axis);
handleWidgetsChange(distributed);
}
// ── Insert static widget ───────────────────────────────────────────────────
function insertWidget(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' } : {},
};
handleWidgetsChange([...iface.widgets, newWidget]);
setSelectedIds([newWidget.id]);
}
// ── Save / export / import ─────────────────────────────────────────────────
async function handleSave() {
setSaving(true);
setError(null);
@@ -63,10 +177,7 @@ export default function EditMode({ initial, onDone }: Props) {
headers: { 'Content-Type': 'application/xml' },
body: xml,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Save failed (${res.status}): ${text}`);
}
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 }));
@@ -98,11 +209,12 @@ export default function EditMode({ initial, onDone }: Props) {
const file = input.files?.[0];
if (!file) return;
try {
const xml = await file.text();
const parsed = parseInterface(xml);
setIface(parsed);
setSelectedId(null);
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}`);
}
@@ -115,23 +227,49 @@ export default function EditMode({ initial, onDone }: Props) {
onDone();
}
function handleDeleteSelected() {
if (!selectedId) return;
setIface(f => ({ ...f, widgets: f.widgets.filter(w => w.id !== selectedId) }));
setSelectedId(null);
setDirty(true);
}
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
function handleKeyDown(e: KeyboardEvent) {
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId &&
!(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) {
handleDeleteSelected();
const target = e.target as Element;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') 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;
}
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
));
}
}
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" onKeyDown={handleKeyDown} tabIndex={-1}>
{/* Toolbar */}
{/* ── Toolbar ── */}
<header class="toolbar">
<div class="toolbar-left">
<span class="app-name">uopi</span>
@@ -143,30 +281,85 @@ export default function EditMode({ initial, onDone }: Props) {
/>
{dirty && <span class="dirty-dot" title="Unsaved changes"></span>}
</div>
<div class="toolbar-center">
{error && <span class="parse-error" title={error}>Save error check console</span>}
<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 */}
<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">
<button class="toolbar-btn" onClick={handleImport} title="Import XML file">Import</button>
<button class="toolbar-btn" onClick={handleExport} title="Export XML file">Export</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}>
{saving ? 'Saving…' : 'Save'}
</button>
<button class="toolbar-btn" onClick={handleLeave} title="Back to view mode"> Close</button>
<button class="toolbar-btn" onClick={handleLeave}> Close</button>
</div>
</header>
{/* Three-panel body */}
{/* ── Three-panel body ── */}
<div class="edit-body">
<SignalTree />
<EditCanvas
iface={iface}
selectedId={selectedId}
onSelect={setSelectedId}
selectedIds={selectedIds}
onSelect={setSelectedIds}
onChange={handleWidgetsChange}
snapGrid={snapGrid}
/>
<PropertiesPane
selected={selectedWidget}
multiCount={multiSelected ? selectedIds.length : 0}
iface={iface}
onChange={handleWidgetChange}
onIfaceChange={handleIfaceChange}