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 = { 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 = { 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(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(null); const resizeRef = useRef(null); const rubberRef = useRef({ 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({ visible: false, x: 0, y: 0, canvasX: 0, canvasY: 0, signal: null, }); const [signalDropMenu, setSignalDropMenu] = useState(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 (
{(iface.widgets || []).map(widget => { const Comp = COMPONENTS[widget.type]; return Comp ? : (
{widget.type}
); })} {(iface.widgets || []).map(widget => { const isSelected = selectedIds.includes(widget.id); return (
handleOverlayMouseDown(e, widget)} > {isSelected && (
{(['n','s','e','w','ne','nw','se','sw'] as const).map(dir => (
startResize(e, widget, dir)} /> ))}
)}
); })} {rubberVis && (
)}
{picker.visible && ( setPicker(p => ({ ...p, visible: false }))} /> )} {/* Signal drop menu: appears when dropping onto an existing widget */} {signalDropMenu && (
setSignalDropMenu(null)}>
e.stopPropagation()} >
Drop signal {signalDropMenu.signal.name}
{MULTI_SIGNAL_TYPES.has(signalDropMenu.targetWidget.type) && ( )} {SINGLE_SIGNAL_TYPES.has(signalDropMenu.targetWidget.type) && ( )}
)}
); }