Improved perfs
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import type { GroupNode, SignalRef } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
onDragStart?: (sig: SignalRef) => void;
|
||||
}
|
||||
|
||||
// ── Pure tree helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function genId(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
/** Insert a child node at the end of the folder at `path` (empty = root). */
|
||||
function insertAt(nodes: GroupNode[], path: number[], child: GroupNode): GroupNode[] {
|
||||
if (path.length === 0) return [...nodes, child];
|
||||
return nodes.map((n, i) => {
|
||||
if (i !== path[0] || n.kind !== 'folder') return n;
|
||||
return { ...n, children: insertAt(n.children, path.slice(1), child) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove the node at `path`. */
|
||||
function removeAt(nodes: GroupNode[], path: number[]): GroupNode[] {
|
||||
if (path.length === 1) return nodes.filter((_, i) => i !== path[0]);
|
||||
return nodes.map((n, i) => {
|
||||
if (i !== path[0] || n.kind !== 'folder') return n;
|
||||
return { ...n, children: removeAt(n.children, path.slice(1)) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Update the folder label at `path`. */
|
||||
function renameAt(nodes: GroupNode[], path: number[], label: string): GroupNode[] {
|
||||
if (path.length === 1) {
|
||||
return nodes.map((n, i) =>
|
||||
i === path[0] && n.kind === 'folder' ? { ...n, label } : n
|
||||
);
|
||||
}
|
||||
return nodes.map((n, i) => {
|
||||
if (i !== path[0] || n.kind !== 'folder') return n;
|
||||
return { ...n, children: renameAt(n.children, path.slice(1), label) };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Parse signal input ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse "ds:name" or bare "name" (defaults to epics). */
|
||||
function parseSignal(input: string): { ds: string; name: string } | null {
|
||||
const s = input.trim();
|
||||
if (!s) return null;
|
||||
const colon = s.indexOf(':');
|
||||
if (colon > 0) return { ds: s.slice(0, colon), name: s.slice(colon + 1) };
|
||||
return { ds: 'epics', name: s };
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function GroupsTree({ onDragStart }: Props) {
|
||||
const [nodes, setNodes] = useState<GroupNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
// What the user is currently adding inline (null = nothing)
|
||||
const [addingAt, setAddingAt] = useState<{ path: number[]; kind: 'signal' | 'folder' } | null>(null);
|
||||
const [addInput, setAddInput] = useState('');
|
||||
|
||||
// Inline folder rename
|
||||
const [renamingPath, setRenamingPath] = useState<number[] | null>(null);
|
||||
const [renameInput, setRenameInput] = useState('');
|
||||
|
||||
// ── Load / save ─────────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/groups')
|
||||
.then(r => r.json())
|
||||
.then((data: GroupNode[]) => { setNodes(data); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function save(next: GroupNode[]) {
|
||||
fetch('/api/v1/groups', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(next),
|
||||
}).catch(e => console.error('groups save failed:', e));
|
||||
}
|
||||
|
||||
function update(next: GroupNode[]) {
|
||||
setNodes(next);
|
||||
save(next);
|
||||
}
|
||||
|
||||
// ── Drag ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeDraggable(ds: string, name: string) {
|
||||
return {
|
||||
draggable: true as const,
|
||||
onDragStart: (e: DragEvent) => {
|
||||
const ref: SignalRef = { ds, name };
|
||||
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
|
||||
onDragStart?.(ref);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Inline add ──────────────────────────────────────────────────────────────
|
||||
|
||||
function startAdding(path: number[], kind: 'signal' | 'folder') {
|
||||
setAddingAt({ path, kind });
|
||||
setAddInput('');
|
||||
}
|
||||
|
||||
function commitAdd() {
|
||||
if (!addingAt) return;
|
||||
const { path, kind } = addingAt;
|
||||
const val = addInput.trim();
|
||||
setAddingAt(null);
|
||||
setAddInput('');
|
||||
if (!val) return;
|
||||
if (kind === 'folder') {
|
||||
const node: GroupNode = { kind: 'folder', id: genId(), label: val, children: [] };
|
||||
update(insertAt(nodes, path, node));
|
||||
} else {
|
||||
const sig = parseSignal(val);
|
||||
if (!sig) return;
|
||||
const node: GroupNode = { kind: 'signal', ds: sig.ds, name: sig.name };
|
||||
update(insertAt(nodes, path, node));
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAdd() {
|
||||
setAddingAt(null);
|
||||
setAddInput('');
|
||||
}
|
||||
|
||||
// ── Rename ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function startRename(path: number[], label: string) {
|
||||
setRenamingPath(path);
|
||||
setRenameInput(label);
|
||||
}
|
||||
|
||||
function commitRename() {
|
||||
if (!renamingPath || !renameInput.trim()) { cancelRename(); return; }
|
||||
update(renameAt(nodes, renamingPath, renameInput.trim()));
|
||||
setRenamingPath(null);
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
setRenamingPath(null);
|
||||
setRenameInput('');
|
||||
}
|
||||
|
||||
// ── Collapse ────────────────────────────────────────────────────────────────
|
||||
|
||||
function toggleCollapse(id: string) {
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderAddRow(path: number[], kind: 'signal' | 'folder') {
|
||||
const isActive = addingAt &&
|
||||
addingAt.kind === kind &&
|
||||
JSON.stringify(addingAt.path) === JSON.stringify(path);
|
||||
if (!isActive) return null;
|
||||
return (
|
||||
<div class="group-add-row">
|
||||
<input
|
||||
class="group-add-input"
|
||||
autoFocus
|
||||
placeholder={kind === 'signal' ? 'ds:name or PV name…' : 'Folder name…'}
|
||||
value={addInput}
|
||||
onInput={(e) => setAddInput((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') commitAdd();
|
||||
if (e.key === 'Escape') cancelAdd();
|
||||
}}
|
||||
/>
|
||||
<button class="icon-btn" onClick={commitAdd}>✓</button>
|
||||
<button class="icon-btn" onClick={cancelAdd}>✕</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderNode(node: GroupNode, path: number[]) {
|
||||
const pathKey = JSON.stringify(path);
|
||||
|
||||
if (node.kind === 'signal') {
|
||||
return (
|
||||
<div
|
||||
key={pathKey}
|
||||
class="group-signal-item"
|
||||
title={`${node.ds}:${node.name}`}
|
||||
{...makeDraggable(node.ds, node.name)}
|
||||
>
|
||||
<span class="group-signal-ds">{node.ds}</span>
|
||||
<span class="group-signal-name">{node.name}</span>
|
||||
<button
|
||||
class="icon-btn group-node-remove"
|
||||
title="Remove from group"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); update(removeAt(nodes, path)); }}
|
||||
>✕</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// folder node
|
||||
const isCollapsed = collapsed.has(node.id);
|
||||
const isRenaming = renamingPath !== null && JSON.stringify(renamingPath) === pathKey;
|
||||
|
||||
return (
|
||||
<div key={pathKey} class="group-folder">
|
||||
<div class="group-folder-header">
|
||||
<span
|
||||
class="group-folder-arrow"
|
||||
onClick={() => toggleCollapse(node.id)}
|
||||
>
|
||||
{isCollapsed ? '▸' : '▾'}
|
||||
</span>
|
||||
|
||||
{isRenaming ? (
|
||||
<input
|
||||
class="group-rename-input"
|
||||
autoFocus
|
||||
value={renameInput}
|
||||
onInput={(e) => setRenameInput((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') commitRename();
|
||||
if (e.key === 'Escape') cancelRename();
|
||||
}}
|
||||
onBlur={commitRename}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
class="group-folder-name"
|
||||
onDblClick={() => startRename(path, node.label)}
|
||||
title="Double-click to rename"
|
||||
>
|
||||
{node.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span class="group-folder-count">{node.children.length}</span>
|
||||
|
||||
<div class="group-folder-actions">
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Add signal"
|
||||
onClick={() => { if (!isCollapsed) toggleCollapse(node.id); startAdding(path, 'signal'); }}
|
||||
>+📶</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Add subfolder"
|
||||
onClick={() => { if (!isCollapsed) toggleCollapse(node.id); startAdding(path, 'folder'); }}
|
||||
>+📁</button>
|
||||
<button
|
||||
class="icon-btn group-node-remove"
|
||||
title="Delete folder"
|
||||
onClick={() => update(removeAt(nodes, path))}
|
||||
>🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div class="group-folder-children">
|
||||
{node.children.map((child, i) => renderNode(child, [...path, i]))}
|
||||
{renderAddRow(path, 'signal')}
|
||||
{renderAddRow(path, 'folder')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isAddingRoot = addingAt !== null && addingAt.path.length === 0;
|
||||
|
||||
return (
|
||||
<div class="groups-tree">
|
||||
{/* Root-level toolbar */}
|
||||
<div class="groups-toolbar">
|
||||
<button
|
||||
class="panel-btn"
|
||||
title="New root group"
|
||||
onClick={() => startAdding([], 'folder')}
|
||||
>
|
||||
+ Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline input for new root folder */}
|
||||
{isAddingRoot && addingAt?.kind === 'folder' && (
|
||||
<div class="group-add-row group-add-root">
|
||||
<input
|
||||
class="group-add-input"
|
||||
autoFocus
|
||||
placeholder="Group name…"
|
||||
value={addInput}
|
||||
onInput={(e) => setAddInput((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') commitAdd();
|
||||
if (e.key === 'Escape') cancelAdd();
|
||||
}}
|
||||
/>
|
||||
<button class="icon-btn" onClick={commitAdd}>✓</button>
|
||||
<button class="icon-btn" onClick={cancelAdd}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="panel-list group-list">
|
||||
{loading ? (
|
||||
<p class="hint">Loading groups…</p>
|
||||
) : nodes.length === 0 && !isAddingRoot ? (
|
||||
<p class="hint">No groups yet — click "+ Group" to create one.</p>
|
||||
) : (
|
||||
nodes.map((node, i) => renderNode(node, [i]))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+26
-1
@@ -1,8 +1,11 @@
|
||||
import { h } from 'preact';
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import type { SignalRef } from './lib/types';
|
||||
import SyntheticWizard from './SyntheticWizard';
|
||||
import SyntheticEditor from './SyntheticEditor';
|
||||
import GroupsTree from './GroupsTree';
|
||||
|
||||
type TreeTab = 'sources' | 'groups';
|
||||
|
||||
interface SignalInfo {
|
||||
name: string;
|
||||
@@ -41,6 +44,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function SignalTree({ onDragStart, width }: Props) {
|
||||
const [treeTab, setTreeTab] = useState<TreeTab>('sources');
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [groups, setGroups] = useState<DsGroup[]>([]);
|
||||
const [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(loadCustom);
|
||||
@@ -222,6 +226,26 @@ export default function SignalTree({ onDragStart, width }: Props) {
|
||||
|
||||
{!collapsed && (
|
||||
<div class="signal-tree-body">
|
||||
{/* Tab bar: Sources | Groups */}
|
||||
<div class="signal-tree-tabs">
|
||||
<button
|
||||
class={`stab${treeTab === 'sources' ? ' stab-active' : ''}`}
|
||||
onClick={() => setTreeTab('sources')}
|
||||
>Sources</button>
|
||||
<button
|
||||
class={`stab${treeTab === 'groups' ? ' stab-active' : ''}`}
|
||||
onClick={() => setTreeTab('groups')}
|
||||
>Groups</button>
|
||||
</div>
|
||||
|
||||
{/* Groups tab */}
|
||||
{treeTab === 'groups' && (
|
||||
<GroupsTree onDragStart={onDragStart} />
|
||||
)}
|
||||
|
||||
{/* Sources tab contents */}
|
||||
{treeTab === 'sources' && <>
|
||||
|
||||
{/* Search / filter bar */}
|
||||
<div class="signal-tree-search">
|
||||
<div class="signal-search-row">
|
||||
@@ -332,6 +356,7 @@ export default function SignalTree({ onDragStart, width }: Props) {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ export interface HistoryPoint {
|
||||
value: any;
|
||||
}
|
||||
|
||||
// A node in the user-defined signal group tree.
|
||||
// Folders contain children (other folders or signals); signals are leaf nodes.
|
||||
export type GroupNode =
|
||||
| { kind: 'folder'; id: string; label: string; children: GroupNode[] }
|
||||
| { kind: 'signal'; ds: string; name: string };
|
||||
|
||||
// A complete HMI interface definition
|
||||
export interface Interface {
|
||||
id: string;
|
||||
|
||||
@@ -1185,6 +1185,145 @@ body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Signal tree tab bar ──────────────────────────────────────────────────── */
|
||||
|
||||
.signal-tree-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stab {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.35rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.stab:hover { color: #94a3b8; }
|
||||
.stab-active { color: #e2e8f0; border-bottom-color: #4a9eff; }
|
||||
|
||||
/* ── Groups tree ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.groups-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.groups-toolbar {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-list { overflow-y: auto; flex: 1; padding: 0.25rem 0; }
|
||||
|
||||
.group-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
.group-add-root { padding: 0.25rem 0.5rem; }
|
||||
.group-add-input {
|
||||
flex: 1;
|
||||
background: #0f1117;
|
||||
border: 1px solid #4a9eff;
|
||||
border-radius: 3px;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.78rem;
|
||||
padding: 2px 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.group-folder { margin-bottom: 1px; }
|
||||
|
||||
.group-folder-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
cursor: default;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
min-height: 1.7rem;
|
||||
}
|
||||
.group-folder-header:hover { background: #1e293b; color: #cbd5e1; }
|
||||
.group-folder-header:hover .group-folder-actions { opacity: 1; }
|
||||
|
||||
.group-folder-arrow { font-size: 0.65rem; flex-shrink: 0; width: 0.9rem; text-align: center; cursor: pointer; }
|
||||
.group-folder-name { flex: 1; font-weight: 600; cursor: pointer; user-select: none; }
|
||||
.group-folder-count {
|
||||
font-size: 0.65rem;
|
||||
color: #475569;
|
||||
background: #1e293b;
|
||||
border-radius: 8px;
|
||||
padding: 0 5px;
|
||||
min-width: 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.group-folder-actions {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.group-folder-actions .icon-btn { font-size: 0.65rem; padding: 1px 3px; }
|
||||
|
||||
.group-rename-input {
|
||||
flex: 1;
|
||||
background: #0f1117;
|
||||
border: 1px solid #4a9eff;
|
||||
border-radius: 3px;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 1px 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.group-folder-children {
|
||||
padding-left: 1rem;
|
||||
border-left: 1px solid #1e293b;
|
||||
margin-left: 0.7rem;
|
||||
}
|
||||
|
||||
.group-signal-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
color: #94a3b8;
|
||||
border-radius: 3px;
|
||||
cursor: grab;
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
.group-signal-item:hover { background: #1e293b; color: #cbd5e1; }
|
||||
.group-signal-item:hover .group-node-remove { opacity: 1; }
|
||||
|
||||
.group-signal-ds {
|
||||
color: #475569;
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.group-signal-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.group-node-remove { opacity: 0; transition: opacity 0.15s; margin-left: auto; flex-shrink: 0; }
|
||||
|
||||
.signal-tree-search {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
|
||||
Reference in New Issue
Block a user