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, }; 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], }; interface PickerState { visible: boolean; x: number; y: number; canvasX: number; canvasY: number; signal: SignalRef | null; } interface DragState { widgetId: string; startMouseX: number; startMouseY: number; startWidgetX: number; startWidgetY: number; } interface ResizeState { widgetId: string; handle: string; // 'se' | 'sw' | 'ne' | 'nw' | 'n' | 's' | 'e' | 'w' startMouseX: number; startMouseY: number; startX: number; startY: number; startW: number; startH: number; } interface Props { iface: Interface; selectedId: string | null; onSelect: (id: string | null) => void; onChange: (widgets: Widget[]) => void; } let nextId = Date.now(); function genId() { return `w${nextId++}`; } export default function EditCanvas({ iface, selectedId, onSelect, onChange }: Props) { const containerRef = useRef(null); const dragRef = useRef(null); const resizeRef = useRef(null); const [picker, setPicker] = useState({ visible: false, x: 0, y: 0, canvasX: 0, canvasY: 0, signal: null, }); // Document-level move/up handlers for drag and resize useEffect(() => { function onMouseMove(e: MouseEvent) { 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); } 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 => widget.id === r.widgetId ? { ...widget, x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) } : widget ); onChange(widgets); } } function onMouseUp() { 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) { if ((e.target as Element).closest('.widget-overlay')) return; onSelect(null); } 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 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; 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 newWidget: Widget = { id: genId(), type, x: Math.round(picker.canvasX - defW / 2), y: Math.round(picker.canvasY - defH / 2), w: defW, h: defH, signals: [picker.signal], options: {}, }; onChange([...iface.widgets, newWidget]); 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); } function startDrag(e: MouseEvent, widget: Widget) { e.stopPropagation(); onSelect(widget.id); dragRef.current = { widgetId: widget.id, startMouseX: e.clientX, startMouseY: e.clientY, startWidgetX: widget.x, startWidgetY: widget.y, }; } function startResize(e: MouseEvent, widget: Widget, handle: string) { e.stopPropagation(); e.preventDefault(); resizeRef.current = { widgetId: widget.id, handle, startMouseX: e.clientX, startMouseY: e.clientY, startX: widget.x, startY: widget.y, startW: widget.w, startH: widget.h, }; } return (
{/* Render live widget previews */} {iface.widgets.map(widget => { const Comp = COMPONENTS[widget.type]; return Comp ? : (
{widget.type}
); })} {/* Overlay divs for interaction — sit above the live widgets */} {iface.widgets.map(widget => { const isSelected = widget.id === selectedId; return (
startDrag(e, widget)} onClick={(e: MouseEvent) => { e.stopPropagation(); onSelect(widget.id); }} > {isSelected && (
{/* Delete button */} {/* Resize handles */} {(['n','s','e','w','ne','nw','se','sw'] as const).map(dir => (
startResize(e, widget, dir)} /> ))}
)}
); })}
{picker.visible && ( setPicker(p => ({ ...p, visible: false }))} /> )}
); }