Implemented epics read/write
This commit is contained in:
+101
-15
@@ -37,6 +37,11 @@ export const DEFAULT_SIZES: Record<string, [number, number]> = {
|
||||
link: [140, 50],
|
||||
};
|
||||
|
||||
// Widget types that support multiple signals (signal can be appended)
|
||||
const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled']);
|
||||
// Widget types that have exactly one signal (signal can be replaced)
|
||||
const SINGLE_SIGNAL_TYPES = new Set(['textview', 'gauge', 'barh', 'barv', 'led', 'setvalue', 'button']);
|
||||
|
||||
interface PickerState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
@@ -46,6 +51,14 @@ interface PickerState {
|
||||
signal: SignalRef | null;
|
||||
}
|
||||
|
||||
interface SignalDropMenuState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
signal: SignalRef;
|
||||
targetWidget: Widget;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
@@ -65,9 +78,9 @@ interface ResizeState {
|
||||
|
||||
interface RubberBand {
|
||||
active: boolean;
|
||||
startX: number; startY: number; // canvas-relative
|
||||
startX: number; startY: number;
|
||||
curX: number; curY: number;
|
||||
clientStartX: number; clientStartY: number; // for scroll adjustment
|
||||
clientStartX: number; clientStartY: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -75,7 +88,7 @@ interface Props {
|
||||
selectedIds: string[];
|
||||
onSelect: (ids: string[]) => void;
|
||||
onChange: (widgets: Widget[]) => void;
|
||||
snapGrid: number; // 0 = off
|
||||
snapGrid: number;
|
||||
}
|
||||
|
||||
let _widgetSeq = Date.now();
|
||||
@@ -95,7 +108,6 @@ function rectsIntersect(
|
||||
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);
|
||||
@@ -111,14 +123,14 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
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,
|
||||
});
|
||||
|
||||
// Set up document-level mouse handlers once
|
||||
const [signalDropMenu, setSignalDropMenu] = useState<SignalDropMenuState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const grid = snapGridRef.current;
|
||||
@@ -213,7 +225,7 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
function handleCanvasMouseDown(e: MouseEvent) {
|
||||
if ((e.target as Element).closest('.widget-overlay')) return;
|
||||
if ((e.target as Element).closest('.widget-picker-backdrop')) return;
|
||||
// Start rubber-band on empty canvas
|
||||
if ((e.target as Element).closest('.signal-drop-backdrop')) return;
|
||||
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) {
|
||||
@@ -232,10 +244,50 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
if (!json) return;
|
||||
let sig: SignalRef;
|
||||
try { sig = JSON.parse(json); } catch { return; }
|
||||
|
||||
const { cx, cy } = getCanvasPos(e);
|
||||
|
||||
// Check if drop lands on an existing widget
|
||||
const hit = iface.widgets.find(w =>
|
||||
cx >= w.x && cx <= w.x + w.w && cy >= w.y && cy <= w.y + w.h
|
||||
);
|
||||
|
||||
if (hit && (MULTI_SIGNAL_TYPES.has(hit.type) || SINGLE_SIGNAL_TYPES.has(hit.type))) {
|
||||
// Offer add/replace vs create-new
|
||||
setSignalDropMenu({ visible: true, x: e.clientX, y: e.clientY, signal: sig, targetWidget: hit });
|
||||
return;
|
||||
}
|
||||
|
||||
// No existing widget hit — open type picker
|
||||
setPicker({ visible: true, x: e.clientX, y: e.clientY, canvasX: cx, canvasY: cy, signal: sig });
|
||||
}
|
||||
|
||||
function handleAddToWidget(widget: Widget, sig: SignalRef) {
|
||||
setSignalDropMenu(null);
|
||||
let updated: Widget;
|
||||
if (MULTI_SIGNAL_TYPES.has(widget.type)) {
|
||||
// Append signal (avoid duplicates)
|
||||
const already = widget.signals.some(s => s.ds === sig.ds && s.name === sig.name);
|
||||
if (already) return;
|
||||
updated = { ...widget, signals: [...widget.signals, sig] };
|
||||
} else {
|
||||
// Replace first signal
|
||||
updated = { ...widget, signals: [sig, ...widget.signals.slice(1)] };
|
||||
}
|
||||
onChange(iface.widgets.map(w => w.id === updated.id ? updated : w));
|
||||
onSelect([updated.id]);
|
||||
}
|
||||
|
||||
function handleSignalDropCreateNew(sig: SignalRef, cx: number, cy: number) {
|
||||
setSignalDropMenu(null);
|
||||
// We don't have canvas coords in the menu state, approximate from screen coords
|
||||
const el = containerRef.current;
|
||||
const rect = el ? el.getBoundingClientRect() : { left: 0, top: 0 };
|
||||
const canvasX = cx - rect.left + (el?.scrollLeft ?? 0);
|
||||
const canvasY = cy - rect.top + (el?.scrollTop ?? 0);
|
||||
setPicker({ visible: true, x: cx, y: cy, canvasX, canvasY, signal: sig });
|
||||
}
|
||||
|
||||
function handlePick(type: string) {
|
||||
if (!picker.signal) return;
|
||||
const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
|
||||
@@ -255,10 +307,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
setPicker(p => ({ ...p, visible: false }));
|
||||
}
|
||||
|
||||
// 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 handleOverlayMouseDown(e: MouseEvent, widget: Widget) {
|
||||
e.stopPropagation();
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
@@ -274,7 +322,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
}
|
||||
onSelect(nextIds);
|
||||
|
||||
// Don't start drag on Ctrl+click deselect
|
||||
if (ctrl && isSelected) return;
|
||||
|
||||
const movers = nextIds.includes(widget.id) ? nextIds : [widget.id];
|
||||
@@ -317,7 +364,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
>
|
||||
<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
|
||||
@@ -330,7 +376,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Interaction overlays */}
|
||||
{iface.widgets.map(widget => {
|
||||
const isSelected = selectedIds.includes(widget.id);
|
||||
return (
|
||||
@@ -361,7 +406,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Rubber-band selection rectangle */}
|
||||
{rubberVis && (
|
||||
<div
|
||||
class="rubber-band"
|
||||
@@ -378,6 +422,48 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
onCancel={() => setPicker(p => ({ ...p, visible: false }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Signal drop menu: appears when dropping onto an existing widget */}
|
||||
{signalDropMenu && (
|
||||
<div class="signal-drop-backdrop" onClick={() => setSignalDropMenu(null)}>
|
||||
<div
|
||||
class="signal-drop-menu"
|
||||
style={`left:${signalDropMenu.x}px;top:${signalDropMenu.y}px;`}
|
||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<div class="signal-drop-header">
|
||||
Drop signal <strong>{signalDropMenu.signal.name}</strong>
|
||||
</div>
|
||||
{MULTI_SIGNAL_TYPES.has(signalDropMenu.targetWidget.type) && (
|
||||
<button
|
||||
class="ctx-item"
|
||||
onClick={() => handleAddToWidget(signalDropMenu.targetWidget, signalDropMenu.signal)}
|
||||
>
|
||||
+ Add signal to this {signalDropMenu.targetWidget.type}
|
||||
</button>
|
||||
)}
|
||||
{SINGLE_SIGNAL_TYPES.has(signalDropMenu.targetWidget.type) && (
|
||||
<button
|
||||
class="ctx-item"
|
||||
onClick={() => handleAddToWidget(signalDropMenu.targetWidget, signalDropMenu.signal)}
|
||||
>
|
||||
↺ Replace signal in this {signalDropMenu.targetWidget.type}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
class="ctx-item"
|
||||
onClick={() => handleSignalDropCreateNew(
|
||||
signalDropMenu.signal,
|
||||
signalDropMenu.x,
|
||||
signalDropMenu.y,
|
||||
)}
|
||||
>
|
||||
+ Create new widget
|
||||
</button>
|
||||
<button class="ctx-item ctx-cancel" onClick={() => setSignalDropMenu(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user