279 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|