phase 7
This commit is contained in:
+234
-41
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user