Phase 6
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user