Files
uopi/web/src/EditCanvas.tsx
T
Martino Ferrari 986f6cd6d8 Phase 6
2026-04-25 22:56:09 +02:00

279 lines
8.6 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,
};
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],
};
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<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const resizeRef = useRef<ResizeState | 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
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 (
<div
class="edit-canvas-container"
ref={containerRef}
onClick={handleCanvasClick}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div
class="canvas-area"
style={`width:${iface.w}px;height:${iface.h}px;`}
>
{/* Render 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;`}
>
<span class="unknown-label">{widget.type}</span>
</div>
);
})}
{/* Overlay divs for interaction — sit above the live widgets */}
{iface.widgets.map(widget => {
const isSelected = widget.id === selectedId;
return (
<div
key={`overlay-${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); }}
>
{isSelected && (
<div>
{/* Delete button */}
<button
class="overlay-delete"
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => { e.stopPropagation(); handleDeleteWidget(widget.id); }}
title="Delete widget"
></button>
{/* Resize handles */}
{(['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>
);
})}
</div>
{picker.visible && (
<WidgetTypePicker
x={picker.x}
y={picker.y}
onPick={handlePick}
onCancel={() => setPicker(p => ({ ...p, visible: false }))}
/>
)}
</div>
);
}