471 lines
16 KiB
TypeScript
471 lines
16 KiB
TypeScript
import { h } from 'preact';
|
|
import { useRef, useEffect, useState } from 'preact/hooks';
|
|
import type { Interface, Widget, SignalRef } from './lib/types';
|
|
import TextView from './widgets/TextView';
|
|
import TextLabel from './widgets/TextLabel';
|
|
import Gauge from './widgets/Gauge';
|
|
import BarH from './widgets/BarH';
|
|
import BarV from './widgets/BarV';
|
|
import Led from './widgets/Led';
|
|
import MultiLed from './widgets/MultiLed';
|
|
import SetValue from './widgets/SetValue';
|
|
import Button from './widgets/Button';
|
|
import PlotWidget from './widgets/PlotWidget';
|
|
import ImageWidget from './widgets/ImageWidget';
|
|
import LinkWidget from './widgets/LinkWidget';
|
|
import WidgetTypePicker from './WidgetTypePicker';
|
|
|
|
const COMPONENTS: Record<string, any> = {
|
|
textview: TextView, textlabel: TextLabel, gauge: Gauge,
|
|
barh: BarH, barv: BarV, led: Led, multiled: MultiLed,
|
|
setvalue: SetValue, button: Button, plot: PlotWidget,
|
|
image: ImageWidget, link: LinkWidget,
|
|
};
|
|
|
|
export const DEFAULT_SIZES: Record<string, [number, number]> = {
|
|
textview: [200, 50],
|
|
textlabel: [150, 36],
|
|
gauge: [120, 120],
|
|
barh: [200, 60],
|
|
barv: [60, 160],
|
|
led: [60, 60],
|
|
multiled: [200, 80],
|
|
setvalue: [200, 60],
|
|
button: [120, 50],
|
|
plot: [400, 200],
|
|
image: [200, 150],
|
|
link: [140, 50],
|
|
};
|
|
|
|
// Widget types that support multiple signals (signal can be appended)
|
|
const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled']);
|
|
// Widget types that have exactly one signal (signal can be replaced)
|
|
const SINGLE_SIGNAL_TYPES = new Set(['textview', 'gauge', 'barh', 'barv', 'led', 'setvalue', 'button']);
|
|
|
|
interface PickerState {
|
|
visible: boolean;
|
|
x: number;
|
|
y: number;
|
|
canvasX: number;
|
|
canvasY: number;
|
|
signal: SignalRef | null;
|
|
}
|
|
|
|
interface SignalDropMenuState {
|
|
visible: boolean;
|
|
x: number;
|
|
y: number;
|
|
signal: SignalRef;
|
|
targetWidget: Widget;
|
|
}
|
|
|
|
interface DragState {
|
|
startMouseX: number;
|
|
startMouseY: number;
|
|
startPositions: Array<{ id: string; x: number; y: number }>;
|
|
}
|
|
|
|
interface ResizeState {
|
|
widgetId: string;
|
|
dir: string;
|
|
startMouseX: number;
|
|
startMouseY: number;
|
|
startX: number;
|
|
startY: number;
|
|
startW: number;
|
|
startH: number;
|
|
}
|
|
|
|
interface RubberBand {
|
|
active: boolean;
|
|
startX: number; startY: number;
|
|
curX: number; curY: number;
|
|
clientStartX: number; clientStartY: number;
|
|
}
|
|
|
|
interface Props {
|
|
iface: Interface;
|
|
selectedIds: string[];
|
|
onSelect: (ids: string[]) => void;
|
|
onChange: (widgets: Widget[]) => void;
|
|
snapGrid: number;
|
|
}
|
|
|
|
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);
|
|
|
|
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 });
|
|
|
|
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,
|
|
});
|
|
|
|
const [signalDropMenu, setSignalDropMenu] = useState<SignalDropMenuState | null>(null);
|
|
|
|
useEffect(() => {
|
|
function onMouseMove(e: MouseEvent) {
|
|
const grid = snapGridRef.current;
|
|
|
|
if (dragRef.current) {
|
|
const d = dragRef.current;
|
|
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 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: snapVal(x, grid), y: snapVal(y, grid), w: snapVal(w, grid), h: snapVal(wh, grid) }
|
|
: widget
|
|
);
|
|
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);
|
|
};
|
|
}, []);
|
|
|
|
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;
|
|
if ((e.target as Element).closest('.widget-picker-backdrop')) return;
|
|
if ((e.target as Element).closest('.signal-drop-backdrop')) return;
|
|
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) {
|
|
e.preventDefault();
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
|
}
|
|
|
|
function handleDrop(e: DragEvent) {
|
|
e.preventDefault();
|
|
const json = e.dataTransfer?.getData('application/json');
|
|
if (!json) return;
|
|
let sig: SignalRef;
|
|
try { sig = JSON.parse(json); } catch { return; }
|
|
|
|
const { cx, cy } = getCanvasPos(e);
|
|
|
|
// Check if drop lands on an existing widget
|
|
const hit = iface.widgets.find(w =>
|
|
cx >= w.x && cx <= w.x + w.w && cy >= w.y && cy <= w.y + w.h
|
|
);
|
|
|
|
if (hit && (MULTI_SIGNAL_TYPES.has(hit.type) || SINGLE_SIGNAL_TYPES.has(hit.type))) {
|
|
// Offer add/replace vs create-new
|
|
setSignalDropMenu({ visible: true, x: e.clientX, y: e.clientY, signal: sig, targetWidget: hit });
|
|
return;
|
|
}
|
|
|
|
// No existing widget hit — open type picker
|
|
setPicker({ visible: true, x: e.clientX, y: e.clientY, canvasX: cx, canvasY: cy, signal: sig });
|
|
}
|
|
|
|
function handleAddToWidget(widget: Widget, sig: SignalRef) {
|
|
setSignalDropMenu(null);
|
|
let updated: Widget;
|
|
if (MULTI_SIGNAL_TYPES.has(widget.type)) {
|
|
// Append signal (avoid duplicates)
|
|
const already = widget.signals.some(s => s.ds === sig.ds && s.name === sig.name);
|
|
if (already) return;
|
|
updated = { ...widget, signals: [...widget.signals, sig] };
|
|
} else {
|
|
// Replace first signal
|
|
updated = { ...widget, signals: [sig, ...widget.signals.slice(1)] };
|
|
}
|
|
onChange(iface.widgets.map(w => w.id === updated.id ? updated : w));
|
|
onSelect([updated.id]);
|
|
}
|
|
|
|
function handleSignalDropCreateNew(sig: SignalRef, cx: number, cy: number) {
|
|
setSignalDropMenu(null);
|
|
// We don't have canvas coords in the menu state, approximate from screen coords
|
|
const el = containerRef.current;
|
|
const rect = el ? el.getBoundingClientRect() : { left: 0, top: 0 };
|
|
const canvasX = cx - rect.left + (el?.scrollLeft ?? 0);
|
|
const canvasY = cy - rect.top + (el?.scrollTop ?? 0);
|
|
setPicker({ visible: true, x: cx, y: cy, canvasX, canvasY, 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: genWidgetId(),
|
|
type,
|
|
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]);
|
|
setPicker(p => ({ ...p, visible: false }));
|
|
}
|
|
|
|
function handleOverlayMouseDown(e: MouseEvent, widget: Widget) {
|
|
e.stopPropagation();
|
|
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);
|
|
|
|
if (ctrl && isSelected) return;
|
|
|
|
const movers = nextIds.includes(widget.id) ? nextIds : [widget.id];
|
|
dragRef.current = {
|
|
startMouseX: e.clientX,
|
|
startMouseY: e.clientY,
|
|
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, dir: string) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
resizeRef.current = {
|
|
widgetId: widget.id,
|
|
dir,
|
|
startMouseX: e.clientX,
|
|
startMouseY: e.clientY,
|
|
startX: widget.x,
|
|
startY: widget.y,
|
|
startW: widget.w,
|
|
startH: widget.h,
|
|
};
|
|
}
|
|
|
|
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}
|
|
onMouseDown={handleCanvasMouseDown}
|
|
onDragOver={handleDragOver}
|
|
onDrop={handleDrop}
|
|
>
|
|
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
|
|
|
|
{(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;`}>
|
|
<span class="unknown-label">{widget.type}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{(iface.widgets || []).map(widget => {
|
|
const isSelected = selectedIds.includes(widget.id);
|
|
|
|
return (
|
|
<div
|
|
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) => handleOverlayMouseDown(e, widget)}
|
|
>
|
|
{isSelected && (
|
|
<div>
|
|
<button
|
|
class="overlay-delete"
|
|
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
|
onClick={(e: MouseEvent) => { e.stopPropagation(); handleDelete(widget.id); }}
|
|
title="Delete widget"
|
|
>✕</button>
|
|
{(['n','s','e','w','ne','nw','se','sw'] as const).map(dir => (
|
|
<div
|
|
key={dir}
|
|
class={`resize-handle resize-${dir}`}
|
|
onMouseDown={(e: MouseEvent) => startResize(e, widget, dir)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{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 && (
|
|
<WidgetTypePicker
|
|
x={picker.x}
|
|
y={picker.y}
|
|
onPick={handlePick}
|
|
onCancel={() => setPicker(p => ({ ...p, visible: false }))}
|
|
/>
|
|
)}
|
|
|
|
{/* Signal drop menu: appears when dropping onto an existing widget */}
|
|
{signalDropMenu && (
|
|
<div class="signal-drop-backdrop" onClick={() => setSignalDropMenu(null)}>
|
|
<div
|
|
class="signal-drop-menu"
|
|
style={`left:${signalDropMenu.x}px;top:${signalDropMenu.y}px;`}
|
|
onClick={(e: MouseEvent) => e.stopPropagation()}
|
|
>
|
|
<div class="signal-drop-header">
|
|
Drop signal <strong>{signalDropMenu.signal.name}</strong>
|
|
</div>
|
|
{MULTI_SIGNAL_TYPES.has(signalDropMenu.targetWidget.type) && (
|
|
<button
|
|
class="ctx-item"
|
|
onClick={() => handleAddToWidget(signalDropMenu.targetWidget, signalDropMenu.signal)}
|
|
>
|
|
+ Add signal to this {signalDropMenu.targetWidget.type}
|
|
</button>
|
|
)}
|
|
{SINGLE_SIGNAL_TYPES.has(signalDropMenu.targetWidget.type) && (
|
|
<button
|
|
class="ctx-item"
|
|
onClick={() => handleAddToWidget(signalDropMenu.targetWidget, signalDropMenu.signal)}
|
|
>
|
|
↺ Replace signal in this {signalDropMenu.targetWidget.type}
|
|
</button>
|
|
)}
|
|
<button
|
|
class="ctx-item"
|
|
onClick={() => handleSignalDropCreateNew(
|
|
signalDropMenu.signal,
|
|
signalDropMenu.x,
|
|
signalDropMenu.y,
|
|
)}
|
|
>
|
|
+ Create new widget
|
|
</button>
|
|
<button class="ctx-item ctx-cancel" onClick={() => setSignalDropMenu(null)}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|