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