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
+179 -74
View File
@@ -22,7 +22,7 @@ const COMPONENTS: Record<string, any> = {
image: ImageWidget, link: LinkWidget,
};
const DEFAULT_SIZES: Record<string, [number, number]> = {
export const DEFAULT_SIZES: Record<string, [number, number]> = {
textview: [200, 50],
textlabel: [150, 36],
gauge: [120, 120],
@@ -47,16 +47,14 @@ interface PickerState {
}
interface DragState {
widgetId: string;
startMouseX: number;
startMouseY: number;
startWidgetX: number;
startWidgetY: number;
startPositions: Array<{ id: string; x: number; y: number }>;
}
interface ResizeState {
widgetId: string;
handle: string; // 'se' | 'sw' | 'ne' | 'nw' | 'n' | 's' | 'e' | 'w'
dir: string;
startMouseX: number;
startMouseY: number;
startX: number;
@@ -65,71 +63,162 @@ interface ResizeState {
startH: number;
}
interface Props {
iface: Interface;
selectedId: string | null;
onSelect: (id: string | null) => void;
onChange: (widgets: Widget[]) => void;
interface RubberBand {
active: boolean;
startX: number; startY: number; // canvas-relative
curX: number; curY: number;
clientStartX: number; clientStartY: number; // for scroll adjustment
}
let nextId = Date.now();
function genId() { return `w${nextId++}`; }
interface Props {
iface: Interface;
selectedIds: string[];
onSelect: (ids: string[]) => void;
onChange: (widgets: Widget[]) => void;
snapGrid: number; // 0 = off
}
export default function EditCanvas({ iface, selectedId, onSelect, onChange }: Props) {
let _widgetSeq = Date.now();
export function genWidgetId() { return `w${_widgetSeq++}`; }
function snapVal(v: number, grid: number) {
return grid > 0 ? Math.round(v / grid) * grid : Math.round(v);
}
function rectsIntersect(
wx: number, wy: number, ww: number, wh: number,
rx: number, ry: number, rw: number, rh: number,
): boolean {
return wx < rx + rw && wx + ww > rx && wy < ry + rh && wy + wh > ry;
}
export default function EditCanvas({ iface, selectedIds, onSelect, onChange, snapGrid }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
// Refs: always hold current values so event handlers don't go stale
const ifaceRef = useRef(iface);
ifaceRef.current = iface;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onSelectRef = useRef(onSelect);
onSelectRef.current = onSelect;
const selectedIdsRef = useRef(selectedIds);
selectedIdsRef.current = selectedIds;
const snapGridRef = useRef(snapGrid);
snapGridRef.current = snapGrid;
const dragRef = useRef<DragState | null>(null);
const resizeRef = useRef<ResizeState | null>(null);
const rubberRef = useRef<RubberBand>({ active: false, startX: 0, startY: 0, curX: 0, curY: 0, clientStartX: 0, clientStartY: 0 });
// Rubber-band visual state (drives re-renders during drag)
const [rubberVis, setRubberVis] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const [picker, setPicker] = useState<PickerState>({
visible: false, x: 0, y: 0, canvasX: 0, canvasY: 0, signal: null,
});
// Document-level move/up handlers for drag and resize
// Set up document-level mouse handlers once
useEffect(() => {
function onMouseMove(e: MouseEvent) {
const grid = snapGridRef.current;
if (dragRef.current) {
const d = dragRef.current;
const dx = e.clientX - d.startMouseX;
const dy = e.clientY - d.startMouseY;
const widgets = iface.widgets.map(w =>
w.id === d.widgetId
? { ...w, x: Math.round(d.startWidgetX + dx), y: Math.round(d.startWidgetY + dy) }
: w
);
onChange(widgets);
const raw_dx = e.clientX - d.startMouseX;
const raw_dy = e.clientY - d.startMouseY;
const newWidgets = ifaceRef.current.widgets.map(w => {
const start = d.startPositions.find(p => p.id === w.id);
if (!start) return w;
return {
...w,
x: snapVal(start.x + raw_dx, grid),
y: snapVal(start.y + raw_dy, grid),
};
});
onChangeRef.current(newWidgets);
}
if (resizeRef.current) {
const r = resizeRef.current;
const dx = e.clientX - r.startMouseX;
const dy = e.clientY - r.startMouseY;
let { startX: x, startY: y, startW: w, startH: h } = r;
const handle = r.handle;
if (handle.includes('e')) w = Math.max(20, w + dx);
if (handle.includes('s')) h = Math.max(20, h + dy);
if (handle.includes('w')) { x = x + dx; w = Math.max(20, w - dx); }
if (handle.includes('n')) { y = y + dy; h = Math.max(20, h - dy); }
const widgets = iface.widgets.map(widget =>
let x = r.startX, y = r.startY, w = r.startW, wh = r.startH;
if (r.dir.includes('e')) w = Math.max(20, w + dx);
if (r.dir.includes('s')) wh = Math.max(20, wh + dy);
if (r.dir.includes('w')) { x = x + dx; w = Math.max(20, w - dx); }
if (r.dir.includes('n')) { y = y + dy; wh = Math.max(20, wh - dy); }
const newWidgets = ifaceRef.current.widgets.map(widget =>
widget.id === r.widgetId
? { ...widget, x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) }
? { ...widget, x: snapVal(x, grid), y: snapVal(y, grid), w: snapVal(w, grid), h: snapVal(wh, grid) }
: widget
);
onChange(widgets);
onChangeRef.current(newWidgets);
}
if (rubberRef.current.active) {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const cx = e.clientX - rect.left + el.scrollLeft;
const cy = e.clientY - rect.top + el.scrollTop;
rubberRef.current.curX = cx;
rubberRef.current.curY = cy;
const rb = rubberRef.current;
const x = Math.min(rb.startX, cx);
const y = Math.min(rb.startY, cy);
setRubberVis({ x, y, w: Math.abs(cx - rb.startX), h: Math.abs(cy - rb.startY) });
}
}
function onMouseUp() {
if (rubberRef.current.active) {
const rb = rubberRef.current;
const x = Math.min(rb.startX, rb.curX);
const y = Math.min(rb.startY, rb.curY);
const rw = Math.abs(rb.curX - rb.startX);
const rh = Math.abs(rb.curY - rb.startY);
if (rw > 4 && rh > 4) {
const hit = ifaceRef.current.widgets
.filter(w => rectsIntersect(w.x, w.y, w.w, w.h, x, y, rw, rh))
.map(w => w.id);
onSelectRef.current(hit);
}
rubberRef.current.active = false;
setRubberVis(null);
}
dragRef.current = null;
resizeRef.current = null;
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
}, [iface.widgets, onChange]);
}, []);
function handleCanvasClick(e: MouseEvent) {
function getCanvasPos(e: MouseEvent): { cx: number; cy: number } {
const el = containerRef.current;
if (!el) return { cx: 0, cy: 0 };
const rect = el.getBoundingClientRect();
return {
cx: e.clientX - rect.left + el.scrollLeft,
cy: e.clientY - rect.top + el.scrollTop,
};
}
function handleCanvasMouseDown(e: MouseEvent) {
if ((e.target as Element).closest('.widget-overlay')) return;
onSelect(null);
if ((e.target as Element).closest('.widget-picker-backdrop')) return;
// Start rubber-band on empty canvas
const { cx, cy } = getCanvasPos(e);
rubberRef.current = { active: true, startX: cx, startY: cy, curX: cx, curY: cy, clientStartX: e.clientX, clientStartY: e.clientY };
if (!e.ctrlKey && !e.metaKey) {
onSelect([]);
}
}
function handleDragOver(e: DragEvent) {
@@ -143,56 +232,67 @@ export default function EditCanvas({ iface, selectedId, onSelect, onChange }: Pr
if (!json) return;
let sig: SignalRef;
try { sig = JSON.parse(json); } catch { return; }
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const scrollLeft = containerRef.current?.scrollLeft ?? 0;
const scrollTop = containerRef.current?.scrollTop ?? 0;
const cx = e.clientX - rect.left + scrollLeft;
const cy = e.clientY - rect.top + scrollTop;
const { cx, cy } = getCanvasPos(e);
setPicker({ visible: true, x: e.clientX, y: e.clientY, canvasX: cx, canvasY: cy, signal: sig });
}
function handlePick(type: string) {
if (!picker.signal) return;
const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
const grid = snapGrid;
const newWidget: Widget = {
id: genId(),
id: genWidgetId(),
type,
x: Math.round(picker.canvasX - defW / 2),
y: Math.round(picker.canvasY - defH / 2),
x: snapVal(picker.canvasX - defW / 2, grid),
y: snapVal(picker.canvasY - defH / 2, grid),
w: defW,
h: defH,
signals: [picker.signal],
options: {},
};
onChange([...iface.widgets, newWidget]);
onSelect(newWidget.id);
onSelect([newWidget.id]);
setPicker(p => ({ ...p, visible: false }));
}
function handleDeleteWidget(id: string) {
onChange(iface.widgets.filter(w => w.id !== id));
if (selectedId === id) onSelect(null);
}
// Called from EditMode when inserting a static widget (no signal)
// This is triggered via the parent placing a widget directly.
// (handled via onInsert prop in extended usage)
function startDrag(e: MouseEvent, widget: Widget) {
function handleOverlayMouseDown(e: MouseEvent, widget: Widget) {
e.stopPropagation();
onSelect(widget.id);
const ctrl = e.ctrlKey || e.metaKey;
const isSelected = selectedIds.includes(widget.id);
let nextIds: string[];
if (ctrl) {
nextIds = isSelected
? selectedIds.filter(id => id !== widget.id)
: [...selectedIds, widget.id];
} else {
nextIds = isSelected ? selectedIds : [widget.id];
}
onSelect(nextIds);
// Don't start drag on Ctrl+click deselect
if (ctrl && isSelected) return;
const movers = nextIds.includes(widget.id) ? nextIds : [widget.id];
dragRef.current = {
widgetId: widget.id,
startMouseX: e.clientX,
startMouseY: e.clientY,
startWidgetX: widget.x,
startWidgetY: widget.y,
startPositions: iface.widgets
.filter(w => movers.includes(w.id))
.map(w => ({ id: w.id, x: w.x, y: w.y })),
};
}
function startResize(e: MouseEvent, widget: Widget, handle: string) {
function startResize(e: MouseEvent, widget: Widget, dir: string) {
e.stopPropagation();
e.preventDefault();
resizeRef.current = {
widgetId: widget.id,
handle,
dir,
startMouseX: e.clientX,
startMouseY: e.clientY,
startX: widget.x,
@@ -202,55 +302,52 @@ export default function EditCanvas({ iface, selectedId, onSelect, onChange }: Pr
};
}
function handleDelete(id: string) {
onChange(iface.widgets.filter(w => w.id !== id));
onSelect(selectedIds.filter(s => s !== id));
}
return (
<div
class="edit-canvas-container"
ref={containerRef}
onClick={handleCanvasClick}
onMouseDown={handleCanvasMouseDown}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div
class="canvas-area"
style={`width:${iface.w}px;height:${iface.h}px;`}
>
{/* Render live widget previews */}
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
{/* Live widget previews */}
{iface.widgets.map(widget => {
const Comp = COMPONENTS[widget.type];
return Comp
? <Comp key={widget.id} widget={widget} />
: (
<div
key={widget.id}
class="unknown-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
>
<div key={widget.id} class="unknown-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}>
<span class="unknown-label">{widget.type}</span>
</div>
);
})}
{/* Overlay divs for interaction — sit above the live widgets */}
{/* Interaction overlays */}
{iface.widgets.map(widget => {
const isSelected = widget.id === selectedId;
const isSelected = selectedIds.includes(widget.id);
return (
<div
key={`overlay-${widget.id}`}
key={`ov-${widget.id}`}
class={`widget-overlay${isSelected ? ' selected' : ''}`}
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onMouseDown={(e: MouseEvent) => startDrag(e, widget)}
onClick={(e: MouseEvent) => { e.stopPropagation(); onSelect(widget.id); }}
onMouseDown={(e: MouseEvent) => handleOverlayMouseDown(e, widget)}
>
{isSelected && (
<div>
{/* Delete button */}
<button
class="overlay-delete"
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => { e.stopPropagation(); handleDeleteWidget(widget.id); }}
onClick={(e: MouseEvent) => { e.stopPropagation(); handleDelete(widget.id); }}
title="Delete widget"
></button>
{/* Resize handles */}
{(['n','s','e','w','ne','nw','se','sw'] as const).map(dir => (
<div
key={dir}
@@ -263,6 +360,14 @@ export default function EditCanvas({ iface, selectedId, onSelect, onChange }: Pr
</div>
);
})}
{/* Rubber-band selection rectangle */}
{rubberVis && (
<div
class="rubber-band"
style={`left:${rubberVis.x}px;top:${rubberVis.y}px;width:${rubberVis.w}px;height:${rubberVis.h}px;`}
/>
)}
</div>
{picker.visible && (
+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}
+7 -2
View File
@@ -4,6 +4,7 @@ import type { Widget, Interface } from './lib/types';
interface Props {
selected: Widget | null;
multiCount: number; // >0 when multiple widgets are selected
iface: Interface;
onChange: (updated: Widget) => void;
onIfaceChange: (updated: Interface) => void;
@@ -30,7 +31,7 @@ function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) =
);
}
export default function PropertiesPane({ selected, iface, onChange, onIfaceChange }: Props) {
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange }: Props) {
const [collapsed, setCollapsed] = useState(false);
function setOpt(key: string, value: string) {
@@ -189,7 +190,11 @@ export default function PropertiesPane({ selected, iface, onChange, onIfaceChang
</div>
)}
{!selected && (
{!selected && multiCount > 1 && (
<p class="hint" style="padding:0.75rem;">{multiCount} widgets selected.<br/>Use align/distribute tools in the toolbar.</p>
)}
{!selected && multiCount === 0 && (
<p class="hint" style="padding:0.75rem;">Select a widget to edit its properties.</p>
)}
</div>
+81 -3
View File
@@ -1026,8 +1026,8 @@ body {
.overlay-delete {
position: absolute;
top: -10px;
right: -10px;
top: -14px;
right: -14px;
width: 18px;
height: 18px;
border-radius: 50%;
@@ -1040,7 +1040,7 @@ body {
align-items: center;
justify-content: center;
line-height: 1;
z-index: 20;
z-index: 30;
padding: 0;
}
@@ -1310,3 +1310,81 @@ body {
.iface-delete { color: #ef4444 !important; }
.iface-delete:hover { background: rgba(239, 68, 68, 0.15) !important; }
/* ── Phase 7 additions ───────────────────────────────────────────────────── */
/* Rubber-band selection rectangle */
.rubber-band {
position: absolute;
pointer-events: none;
border: 1px solid #60a5fa;
background: rgba(96, 165, 250, 0.08);
z-index: 50;
box-sizing: border-box;
}
/* Edit toolbar tools (center section) */
.edit-toolbar-tools {
display: flex;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
justify-content: center;
}
.toolbar-sep {
width: 1px;
height: 18px;
background: #2d3748;
margin: 0 0.1rem;
flex-shrink: 0;
}
.toolbar-btn.icon-only {
padding: 0.25rem 0.45rem;
font-size: 0.75rem;
}
.toolbar-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
/* Snap-to-grid toggle */
.snap-toggle {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: #94a3b8;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.snap-toggle input { cursor: pointer; accent-color: #4a9eff; }
/* Insert dropdown */
.toolbar-dropdown {
position: relative;
}
.toolbar-dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 9000;
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
min-width: 140px;
padding: 4px 0;
}
/* Align toolbar */
.align-toolbar {
display: flex;
align-items: center;
gap: 0.2rem;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { Widget } from '../lib/types';
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function TextLabel({ widget, onContextMenu }: Props) {
const text = widget.options['text'] ?? '';
const text = widget.options['label'] ?? widget.options['text'] ?? '';
const fontSize = widget.options['fontSize'] ?? '1rem';
const color = widget.options['color'] ?? '#e2e8f0';
const align = widget.options['align'] ?? 'left';