446 lines
20 KiB
TypeScript
446 lines
20 KiB
TypeScript
import { h } from 'preact';
|
|
import { useState, useEffect } from 'preact/hooks';
|
|
import type { Widget, Interface } from './lib/types';
|
|
|
|
interface Props {
|
|
selected: Widget | null;
|
|
multiCount: number;
|
|
iface: Interface;
|
|
onChange: (updated: Widget) => void;
|
|
onIfaceChange: (updated: Interface) => void;
|
|
width?: number;
|
|
}
|
|
|
|
function Field({ label, children }: { label: string; children: any }) {
|
|
return (
|
|
<div class="prop-field">
|
|
<label class="prop-label">{label}</label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
|
const [local, setLocal] = useState(value || '');
|
|
|
|
// Sync when external value changes (e.g. different widget selected)
|
|
useEffect(() => {
|
|
setLocal(value || '');
|
|
}, [value]);
|
|
|
|
return (
|
|
<input
|
|
class="prop-input"
|
|
value={local}
|
|
onInput={(e) => setLocal((e.target as HTMLInputElement).value)}
|
|
onChange={(e) => onCommit((e.target as HTMLInputElement).value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
onCommit((e.target as HTMLInputElement).value);
|
|
(e.target as HTMLInputElement).blur();
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Widgets that show units from signal metadata (can be overridden)
|
|
const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']);
|
|
// Widgets where the user can set a numeric format string
|
|
const FORMAT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv']);
|
|
// Widgets with a signal-based label (can be overridden)
|
|
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled', 'toggle']);
|
|
// Widgets with multiple signals
|
|
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
|
|
|
|
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange, width }: Props) {
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
|
|
|
|
// Config sets are only needed by the Config Selector widget; fetch lazily the
|
|
// first time one is selected.
|
|
useEffect(() => {
|
|
if (selected?.type !== 'configselect' || configSets.length > 0) return;
|
|
fetch('/api/v1/config/sets')
|
|
.then(r => r.ok ? r.json() : [])
|
|
.then((list: { id: string; name: string }[]) => setConfigSets(list ?? []))
|
|
.catch(() => {});
|
|
}, [selected?.type]);
|
|
|
|
function setOpt(key: string, value: string) {
|
|
if (!selected) return;
|
|
onChange({ ...selected, options: { ...selected.options, [key]: value } });
|
|
}
|
|
|
|
function setProp(key: keyof Widget, value: any) {
|
|
if (!selected) return;
|
|
onChange({ ...selected, [key]: value } as Widget);
|
|
}
|
|
|
|
function removeSignal(idx: number) {
|
|
if (!selected) return;
|
|
const signals = selected.signals.filter((_, i) => i !== idx);
|
|
onChange({ ...selected, signals });
|
|
}
|
|
|
|
const w = selected;
|
|
|
|
return (
|
|
<aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style={`border-left:1px solid #2d3748;border-right:none;${!collapsed && width ? `width:${width}px;min-width:${width}px;` : ''}`}>
|
|
<div class="panel-header">
|
|
{!collapsed && <span class="panel-title">Properties</span>}
|
|
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
|
|
{collapsed ? '◀' : '▶'}
|
|
</button>
|
|
</div>
|
|
|
|
{!collapsed && (
|
|
<div class="props-body">
|
|
{/* Canvas-level properties */}
|
|
<div class="props-section">
|
|
<div class="props-section-title">{iface.kind === 'plot' ? 'Plot panel' : 'Canvas'}</div>
|
|
<Field label="Name">
|
|
<TextInput
|
|
value={iface.name}
|
|
onCommit={(v) => onIfaceChange({ ...iface, name: v })}
|
|
/>
|
|
</Field>
|
|
{/* Plot panels fill the viewport via the split layout — fixed px size
|
|
is not meaningful, so width/height are hidden. */}
|
|
{iface.kind !== 'plot' && (
|
|
<div>
|
|
<Field label="Width">
|
|
<input
|
|
class="prop-input prop-input-num"
|
|
type="number"
|
|
value={iface.w}
|
|
min={100}
|
|
onChange={(e) => onIfaceChange({ ...iface, w: parseInt((e.target as HTMLInputElement).value, 10) || iface.w })}
|
|
/>
|
|
</Field>
|
|
<Field label="Height">
|
|
<input
|
|
class="prop-input prop-input-num"
|
|
type="number"
|
|
value={iface.h}
|
|
min={100}
|
|
onChange={(e) => onIfaceChange({ ...iface, h: parseInt((e.target as HTMLInputElement).value, 10) || iface.h })}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{w && (
|
|
<div class="props-section" key={w.id}>
|
|
<div class="props-section-title">{w.type}</div>
|
|
|
|
{/* Position / size */}
|
|
<Field label="X">
|
|
<input class="prop-input prop-input-num" type="number" value={w.x}
|
|
onChange={(e) => setProp('x', parseFloat((e.target as HTMLInputElement).value) || 0)} />
|
|
</Field>
|
|
<Field label="Y">
|
|
<input class="prop-input prop-input-num" type="number" value={w.y}
|
|
onChange={(e) => setProp('y', parseFloat((e.target as HTMLInputElement).value) || 0)} />
|
|
</Field>
|
|
<Field label="Width">
|
|
<input class="prop-input prop-input-num" type="number" value={w.w} min={10}
|
|
onChange={(e) => setProp('w', parseFloat((e.target as HTMLInputElement).value) || 10)} />
|
|
</Field>
|
|
<Field label="Height">
|
|
<input class="prop-input prop-input-num" type="number" value={w.h} min={10}
|
|
onChange={(e) => setProp('h', parseFloat((e.target as HTMLInputElement).value) || 10)} />
|
|
</Field>
|
|
|
|
{/* Signals */}
|
|
{MULTI_SIGNAL_WIDGETS.has(w.type) ? (
|
|
<Field label="Signals">
|
|
<div class="prop-signals-list">
|
|
{w.signals.length === 0 && <span class="hint">No signals</span>}
|
|
{w.signals.map((s, i) => (
|
|
<div key={i} class="prop-signal-row">
|
|
<span class="prop-sig">{s.ds}:{s.name}</span>
|
|
<button
|
|
class="icon-btn prop-sig-remove"
|
|
title="Remove signal"
|
|
onClick={() => removeSignal(i)}
|
|
>✕</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Field>
|
|
) : w.signals.length > 0 ? (
|
|
<Field label="Signal">
|
|
<span class="prop-sig">{w.signals[0].ds}:{w.signals[0].name}</span>
|
|
</Field>
|
|
) : null}
|
|
|
|
{/* ── Common label/unit overrides (issue #8) ──────────────────── */}
|
|
|
|
{/* Label: button and textlabel use it as display text;
|
|
signal widgets use it as header label */}
|
|
{w.type !== 'image' && w.type !== 'link' && (
|
|
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
|
|
{LABEL_WIDGETS.has(w.type) ? (
|
|
<div class="prop-field-col">
|
|
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
|
<span class="prop-hint">Empty = signal name</span>
|
|
</div>
|
|
) : (
|
|
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
|
)}
|
|
</Field>
|
|
)}
|
|
|
|
{/* Unit override for signal widgets */}
|
|
{UNIT_WIDGETS.has(w.type) && (
|
|
<Field label="Unit">
|
|
<div class="prop-field-col">
|
|
<TextInput value={w.options['unit'] ?? ''} onCommit={(v) => setOpt('unit', v)} />
|
|
<span class="prop-hint">Empty = from meta · "none" = hide</span>
|
|
</div>
|
|
</Field>
|
|
)}
|
|
|
|
{/* Format string for numeric display */}
|
|
{FORMAT_WIDGETS.has(w.type) && (
|
|
<Field label="Format">
|
|
<div class="prop-field-col">
|
|
<TextInput value={w.options['format'] ?? ''} onCommit={(v) => setOpt('format', v)} />
|
|
<span class="prop-hint">3f · 2e · 4g · empty=auto</span>
|
|
</div>
|
|
</Field>
|
|
)}
|
|
|
|
{/* ── Type-specific options ──────────────────────────────────── */}
|
|
|
|
{(w.type === 'gauge' || w.type === 'barh' || w.type === 'barv') && (
|
|
<div>
|
|
<Field label="Min">
|
|
<TextInput value={w.options['min'] ?? '0'} onCommit={(v) => setOpt('min', v)} />
|
|
</Field>
|
|
<Field label="Max">
|
|
<TextInput value={w.options['max'] ?? '100'} onCommit={(v) => setOpt('max', v)} />
|
|
</Field>
|
|
</div>
|
|
)}
|
|
|
|
{w.type === 'led' && (
|
|
<Field label="Condition">
|
|
<TextInput value={w.options['condition'] ?? '>0'} onCommit={(v) => setOpt('condition', v)} />
|
|
</Field>
|
|
)}
|
|
|
|
{w.type === 'setvalue' && (
|
|
<div>
|
|
<Field label="Min">
|
|
<TextInput value={w.options['min'] ?? ''} onCommit={(v) => setOpt('min', v)} />
|
|
</Field>
|
|
<Field label="Confirm dialog">
|
|
<select class="prop-select" value={w.options['confirm'] ?? 'false'}
|
|
onChange={(e) => setOpt('confirm', (e.target as HTMLSelectElement).value)}>
|
|
<option value="false">No</option>
|
|
<option value="true">Yes</option>
|
|
</select>
|
|
</Field>
|
|
</div>
|
|
)}
|
|
|
|
{w.type === 'toggle' && (
|
|
<div>
|
|
<Field label="On value">
|
|
<TextInput value={w.options['onValue'] ?? '1'} onCommit={(v) => setOpt('onValue', v)} />
|
|
</Field>
|
|
<Field label="Off value">
|
|
<TextInput value={w.options['offValue'] ?? '0'} onCommit={(v) => setOpt('offValue', v)} />
|
|
</Field>
|
|
<Field label="On label">
|
|
<TextInput value={w.options['onLabel'] ?? ''} onCommit={(v) => setOpt('onLabel', v)} />
|
|
</Field>
|
|
<Field label="Off label">
|
|
<TextInput value={w.options['offLabel'] ?? ''} onCommit={(v) => setOpt('offLabel', v)} />
|
|
</Field>
|
|
<Field label="Confirm dialog">
|
|
<select class="prop-select" value={w.options['confirm'] ?? 'false'}
|
|
onChange={(e) => setOpt('confirm', (e.target as HTMLSelectElement).value)}>
|
|
<option value="false">No</option>
|
|
<option value="true">Yes</option>
|
|
</select>
|
|
</Field>
|
|
</div>
|
|
)}
|
|
|
|
{/* Button options (issue #2) */}
|
|
{w.type === 'button' && (
|
|
<div>
|
|
<Field label="Send value">
|
|
<TextInput value={w.options['value'] ?? '1'} onCommit={(v) => setOpt('value', v)} />
|
|
</Field>
|
|
<Field label="Mode">
|
|
<select class="prop-select" value={w.options['mode'] ?? 'oneshot'}
|
|
onChange={(e) => setOpt('mode', (e.target as HTMLSelectElement).value)}>
|
|
<option value="oneshot">One-shot (set once)</option>
|
|
<option value="setReset">Set-reset (auto-revert)</option>
|
|
<option value="persistent">Persistent (toggle / monitor)</option>
|
|
</select>
|
|
</Field>
|
|
{(w.options['mode'] === 'setReset' || w.options['mode'] === 'persistent') && (
|
|
<Field label="Reset value">
|
|
<TextInput value={w.options['resetValue'] ?? '0'} onCommit={(v) => setOpt('resetValue', v)} />
|
|
</Field>
|
|
)}
|
|
{w.options['mode'] === 'setReset' && (
|
|
<Field label="Reset after (s)">
|
|
<TextInput value={w.options['resetTime'] ?? '1'} onCommit={(v) => setOpt('resetTime', v)} />
|
|
</Field>
|
|
)}
|
|
<Field label="Confirm dialog">
|
|
<select class="prop-select" value={w.options['confirm'] ?? 'false'}
|
|
onChange={(e) => setOpt('confirm', (e.target as HTMLSelectElement).value)}>
|
|
<option value="false">No</option>
|
|
<option value="true">Yes</option>
|
|
</select>
|
|
</Field>
|
|
<Field label="Action">
|
|
<div class="prop-field-col">
|
|
<select
|
|
class="prop-input"
|
|
value={w.options['action'] ?? ''}
|
|
onChange={(e) => setOpt('action', (e.target as HTMLSelectElement).value)}
|
|
>
|
|
<option value="">(none)</option>
|
|
{(iface.logic?.nodes ?? [])
|
|
.filter(n => n.kind === 'trigger.button')
|
|
.map(n => (
|
|
<option key={n.id} value={n.params.name ?? ''}>{n.params.name || '(unnamed)'}</option>
|
|
))}
|
|
</select>
|
|
<span class="prop-hint">Fires a Button trigger node (define flows in the Logic tab)</span>
|
|
</div>
|
|
</Field>
|
|
</div>
|
|
)}
|
|
|
|
{/* Plot options */}
|
|
{w.type === 'plot' && (
|
|
<div>
|
|
<Field label="Plot type">
|
|
<select
|
|
class="prop-select"
|
|
value={w.options['plotType'] ?? 'timeseries'}
|
|
onChange={(e) => setOpt('plotType', (e.target as HTMLSelectElement).value)}
|
|
>
|
|
<option value="timeseries">Time-series</option>
|
|
<option value="histogram">Histogram</option>
|
|
<option value="bar">Bar chart</option>
|
|
<option value="fft">FFT</option>
|
|
<option value="waterfall">Waterfall</option>
|
|
<option value="logic">Logic analyser</option>
|
|
<option value="waveform">Waveform (array)</option>
|
|
</select>
|
|
</Field>
|
|
{(w.options['plotType'] ?? 'timeseries') === 'timeseries' && (
|
|
<Field label="Time window (s)">
|
|
<TextInput value={w.options['timeWindow'] ?? '60'} onCommit={(v) => setOpt('timeWindow', v)} />
|
|
</Field>
|
|
)}
|
|
<Field label="Y min">
|
|
<TextInput value={w.options['yMin'] ?? 'auto'} onCommit={(v) => setOpt('yMin', v)} />
|
|
</Field>
|
|
<Field label="Y max">
|
|
<TextInput value={w.options['yMax'] ?? 'auto'} onCommit={(v) => setOpt('yMax', v)} />
|
|
</Field>
|
|
<Field label="Y format">
|
|
<div class="prop-field-col">
|
|
<TextInput value={w.options['format'] ?? ''} onCommit={(v) => setOpt('format', v)} />
|
|
<span class="prop-hint">3f · 2e · 4g · empty=auto</span>
|
|
</div>
|
|
</Field>
|
|
<Field label="Legend">
|
|
<select class="prop-select" value={w.options['legend'] ?? 'bottom'}
|
|
onChange={(e) => setOpt('legend', (e.target as HTMLSelectElement).value)}>
|
|
<option value="bottom">Bottom</option>
|
|
<option value="top">Top</option>
|
|
<option value="none">None</option>
|
|
</select>
|
|
</Field>
|
|
</div>
|
|
)}
|
|
|
|
{w.type === 'textlabel' && (
|
|
<div>
|
|
<Field label="Font size">
|
|
<TextInput value={w.options['fontSize'] ?? '14'} onCommit={(v) => setOpt('fontSize', v)} />
|
|
</Field>
|
|
<Field label="Color">
|
|
<TextInput value={w.options['color'] ?? '#e2e8f0'} onCommit={(v) => setOpt('color', v)} />
|
|
</Field>
|
|
</div>
|
|
)}
|
|
|
|
{w.type === 'image' && (
|
|
<Field label="URL">
|
|
<TextInput value={w.options['url'] ?? ''} onCommit={(v) => setOpt('url', v)} />
|
|
</Field>
|
|
)}
|
|
|
|
{w.type === 'link' && (
|
|
<div>
|
|
<Field label="Target">
|
|
<TextInput value={w.options['target'] ?? ''} onCommit={(v) => setOpt('target', v)} />
|
|
</Field>
|
|
<Field label="Label">
|
|
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
|
</Field>
|
|
</div>
|
|
)}
|
|
|
|
{w.type === 'configselect' && (
|
|
<div>
|
|
<Field label="Label">
|
|
<TextInput value={w.options['label'] ?? 'Config'} onCommit={(v) => setOpt('label', v)} />
|
|
</Field>
|
|
<Field label="Config set">
|
|
<select class="prop-select" value={w.options['set'] ?? ''}
|
|
onChange={(e) => setOpt('set', (e.target as HTMLSelectElement).value)}>
|
|
<option value="">choose…</option>
|
|
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
|
|
</select>
|
|
</Field>
|
|
<Field label="Output variable">
|
|
<div class="prop-field-col">
|
|
<select class="prop-input" value={w.options['output'] ?? ''}
|
|
onChange={(e) => setOpt('output', (e.target as HTMLSelectElement).value)}>
|
|
<option value="">(none)</option>
|
|
{(iface.statevars ?? []).map(v => (
|
|
<option key={v.name} value={v.name}>{v.name}</option>
|
|
))}
|
|
</select>
|
|
<span class="prop-hint">A panel variable (define one of type "string" in the Variables tab); receives the selected instance id.</span>
|
|
</div>
|
|
</Field>
|
|
<Field label="Instance subset">
|
|
<div class="prop-field-col">
|
|
<TextInput value={w.options['instances'] ?? ''} onCommit={(v) => setOpt('instances', v)} />
|
|
<span class="prop-hint">Comma-separated instance ids to show. Empty = all instances of the set.</span>
|
|
</div>
|
|
</Field>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!w && multiCount > 1 && (
|
|
<p class="hint" style="padding:0.75rem;">{multiCount} widgets selected.<br/>Use align/distribute tools in the toolbar.</p>
|
|
)}
|
|
|
|
{!w && multiCount === 0 && (
|
|
<p class="hint" style="padding:0.75rem;">Select a widget to edit its properties.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|