phase 7
This commit is contained in:
+179
-74
@@ -22,7 +22,7 @@ const COMPONENTS: Record<string, any> = {
|
||||
image: ImageWidget, link: LinkWidget,
|
||||
};
|
||||
|
||||
const DEFAULT_SIZES: Record<string, [number, number]> = {
|
||||
export const DEFAULT_SIZES: Record<string, [number, number]> = {
|
||||
textview: [200, 50],
|
||||
textlabel: [150, 36],
|
||||
gauge: [120, 120],
|
||||
@@ -47,16 +47,14 @@ interface PickerState {
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
widgetId: string;
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
startWidgetX: number;
|
||||
startWidgetY: number;
|
||||
startPositions: Array<{ id: string; x: number; y: number }>;
|
||||
}
|
||||
|
||||
interface ResizeState {
|
||||
widgetId: string;
|
||||
handle: string; // 'se' | 'sw' | 'ne' | 'nw' | 'n' | 's' | 'e' | 'w'
|
||||
dir: string;
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
startX: number;
|
||||
@@ -65,71 +63,162 @@ interface ResizeState {
|
||||
startH: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
iface: Interface;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onChange: (widgets: Widget[]) => void;
|
||||
interface RubberBand {
|
||||
active: boolean;
|
||||
startX: number; startY: number; // canvas-relative
|
||||
curX: number; curY: number;
|
||||
clientStartX: number; clientStartY: number; // for scroll adjustment
|
||||
}
|
||||
|
||||
let nextId = Date.now();
|
||||
function genId() { return `w${nextId++}`; }
|
||||
interface Props {
|
||||
iface: Interface;
|
||||
selectedIds: string[];
|
||||
onSelect: (ids: string[]) => void;
|
||||
onChange: (widgets: Widget[]) => void;
|
||||
snapGrid: number; // 0 = off
|
||||
}
|
||||
|
||||
export default function EditCanvas({ iface, selectedId, onSelect, onChange }: Props) {
|
||||
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<HTMLDivElement>(null);
|
||||
|
||||
// Refs: always hold current values so event handlers don't go stale
|
||||
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<DragState | null>(null);
|
||||
const resizeRef = useRef<ResizeState | null>(null);
|
||||
const rubberRef = useRef<RubberBand>({ active: false, startX: 0, startY: 0, curX: 0, curY: 0, clientStartX: 0, clientStartY: 0 });
|
||||
|
||||
// Rubber-band visual state (drives re-renders during drag)
|
||||
const [rubberVis, setRubberVis] = useState<{ x: number; y: number; w: number; h: number } | 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
|
||||
// Set up document-level mouse handlers once
|
||||
useEffect(() => {
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const grid = snapGridRef.current;
|
||||
|
||||
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);
|
||||
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 { 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 =>
|
||||
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: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) }
|
||||
? { ...widget, x: snapVal(x, grid), y: snapVal(y, grid), w: snapVal(w, grid), h: snapVal(wh, grid) }
|
||||
: widget
|
||||
);
|
||||
onChange(widgets);
|
||||
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);
|
||||
};
|
||||
}, [iface.widgets, onChange]);
|
||||
}, []);
|
||||
|
||||
function handleCanvasClick(e: MouseEvent) {
|
||||
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;
|
||||
onSelect(null);
|
||||
if ((e.target as Element).closest('.widget-picker-backdrop')) return;
|
||||
// Start rubber-band on empty canvas
|
||||
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) {
|
||||
@@ -143,56 +232,67 @@ export default function EditCanvas({ iface, selectedId, onSelect, onChange }: Pr
|
||||
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;
|
||||
const { cx, cy } = getCanvasPos(e);
|
||||
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 grid = snapGrid;
|
||||
const newWidget: Widget = {
|
||||
id: genId(),
|
||||
id: genWidgetId(),
|
||||
type,
|
||||
x: Math.round(picker.canvasX - defW / 2),
|
||||
y: Math.round(picker.canvasY - defH / 2),
|
||||
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);
|
||||
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);
|
||||
}
|
||||
// Called from EditMode when inserting a static widget (no signal)
|
||||
// This is triggered via the parent placing a widget directly.
|
||||
// (handled via onInsert prop in extended usage)
|
||||
|
||||
function startDrag(e: MouseEvent, widget: Widget) {
|
||||
function handleOverlayMouseDown(e: MouseEvent, widget: Widget) {
|
||||
e.stopPropagation();
|
||||
onSelect(widget.id);
|
||||
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);
|
||||
|
||||
// Don't start drag on Ctrl+click deselect
|
||||
if (ctrl && isSelected) return;
|
||||
|
||||
const movers = nextIds.includes(widget.id) ? nextIds : [widget.id];
|
||||
dragRef.current = {
|
||||
widgetId: widget.id,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
startWidgetX: widget.x,
|
||||
startWidgetY: widget.y,
|
||||
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, handle: string) {
|
||||
function startResize(e: MouseEvent, widget: Widget, dir: string) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
resizeRef.current = {
|
||||
widgetId: widget.id,
|
||||
handle,
|
||||
dir,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
startX: widget.x,
|
||||
@@ -202,55 +302,52 @@ export default function EditCanvas({ iface, selectedId, onSelect, onChange }: Pr
|
||||
};
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
onChange(iface.widgets.filter(w => w.id !== id));
|
||||
onSelect(selectedIds.filter(s => s !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="edit-canvas-container"
|
||||
ref={containerRef}
|
||||
onClick={handleCanvasClick}
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div
|
||||
class="canvas-area"
|
||||
style={`width:${iface.w}px;height:${iface.h}px;`}
|
||||
>
|
||||
{/* Render live widget previews */}
|
||||
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
|
||||
|
||||
{/* 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;`}
|
||||
>
|
||||
<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 */}
|
||||
{/* Interaction overlays */}
|
||||
{iface.widgets.map(widget => {
|
||||
const isSelected = widget.id === selectedId;
|
||||
const isSelected = selectedIds.includes(widget.id);
|
||||
return (
|
||||
<div
|
||||
key={`overlay-${widget.id}`}
|
||||
key={`ov-${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); }}
|
||||
onMouseDown={(e: MouseEvent) => handleOverlayMouseDown(e, widget)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div>
|
||||
{/* Delete button */}
|
||||
<button
|
||||
class="overlay-delete"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); handleDeleteWidget(widget.id); }}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); handleDelete(widget.id); }}
|
||||
title="Delete widget"
|
||||
>✕</button>
|
||||
{/* Resize handles */}
|
||||
{(['n','s','e','w','ne','nw','se','sw'] as const).map(dir => (
|
||||
<div
|
||||
key={dir}
|
||||
@@ -263,6 +360,14 @@ export default function EditCanvas({ iface, selectedId, onSelect, onChange }: Pr
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Rubber-band selection rectangle */}
|
||||
{rubberVis && (
|
||||
<div
|
||||
class="rubber-band"
|
||||
style={`left:${rubberVis.x}px;top:${rubberVis.y}px;width:${rubberVis.w}px;height:${rubberVis.h}px;`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{picker.visible && (
|
||||
|
||||
Reference in New Issue
Block a user