clean up node menu

This commit is contained in:
2026-04-16 21:37:03 -07:00
parent 924b29757f
commit a4c8d2b01c
5 changed files with 110 additions and 20 deletions

View File

@@ -3,6 +3,7 @@ import { socketSpecAcceptsType } from './constants';
import { outputTypeCanConnectToTarget } from './connectionUtils';
import { compareMenuNodes, compareMenuCategories } from './canvasEvents';
import { useFavorites } from './favorites';
import { recordUsage, pickWeightedRandom } from './nodeUsage';
const FAVORITES_CATEGORY = 'favorites';
@@ -213,6 +214,29 @@ export default function ContextMenu({
setOpenCat(cat);
}, []);
const allNodeEntries = useMemo(() => {
const map = new Map<string, any>();
for (const category of categories) {
for (const item of category.items) {
if (!map.has(item.className)) map.set(item.className, item.def);
}
}
return map;
}, [categories]);
const handleAdd = useCallback((className: string, def: any) => {
recordUsage(className);
onAdd(className, def);
}, [onAdd]);
const handleRandomNode = useCallback(() => {
const classNames = [...allNodeEntries.keys()];
const pick = pickWeightedRandom(classNames);
if (!pick) return;
const def = allNodeEntries.get(pick);
if (def) { handleAdd(pick, def); onClose(); }
}, [allNodeEntries, handleAdd, onClose]);
if (categories.length === 0) {
return (
<div className="context-menu" ref={menuRef} style={{ left: menuPos.x, top: menuPos.y }} onClick={(e) => e.stopPropagation()}>
@@ -256,7 +280,7 @@ export default function ContextMenu({
className="context-item"
onClick={() => { onCreateGroup(); onClose(); }}
>
create group
Create Group
</div>
)}
@@ -270,7 +294,7 @@ export default function ContextMenu({
key={className}
ref={idx === selectedIndex ? selectedItemRef : null}
className={`context-item${idx === selectedIndex ? ' context-item--selected' : ''}`}
onClick={() => { onAdd(className, def); onClose(); }}
onClick={() => { handleAdd(className, def); onClose(); }}
onMouseEnter={() => setSelectedIndex(idx)}
>
{def.display_name || className}
@@ -288,11 +312,18 @@ export default function ContextMenu({
onMouseEnter={() => handleCatEnter(cat)}
>
<span className="ctx-cat-label">
{cat === FAVORITES_CATEGORY ? '♥ favorites' : cat}
{cat === FAVORITES_CATEGORY ? 'Favorites' : cat}
</span>
<span className="ctx-cat-arrow"></span>
</div>
))}
<div
className="ctx-cat-item ctx-random-node"
onClick={handleRandomNode}
onMouseEnter={() => setOpenCat(null)}
>
<span className="ctx-cat-label">surprise me</span>
</div>
</div>
)}
</div>
@@ -314,7 +345,7 @@ export default function ContextMenu({
<div
key={className}
className="context-item"
onClick={() => { onAdd(className, def); onClose(); }}
onClick={() => { handleAdd(className, def); onClose(); }}
>
{def.display_name || className}
</div>

View File

@@ -1426,15 +1426,17 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
{/* Interactive 3D surface view */}
{!!data.meshData && (
<CollapsibleSection title="3D View" defaultOpen={true}>
<Suspense fallback={<div className="node-preview" style={{color:'var(--text-muted)',padding:4}}>Loading 3D...</div>}>
<SurfaceView
meshData={data.meshData as any}
nodeId={id}
widgetValues={data.widgetValues}
runtimeValues={data.runtimeValues}
onRuntimeValuesChange={ctx?.onRuntimeValuesChange}
/>
</Suspense>
<PreviewBoundary resetKey={String(data.meshData)}>
<Suspense fallback={<div className="node-preview" style={{color:'var(--text-muted)',padding:4}}>Loading 3D...</div>}>
<SurfaceView
meshData={data.meshData as any}
nodeId={id}
widgetValues={data.widgetValues}
runtimeValues={data.runtimeValues}
onRuntimeValuesChange={ctx?.onRuntimeValuesChange}
/>
</Suspense>
</PreviewBoundary>
</CollapsibleSection>
)}

View File

@@ -133,6 +133,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
const pointerEnteredAtRef = useRef(0);
const lastWheelAtRef = useRef(0);
const gestureStartedInsideRef = useRef(false);
const scheduleViewportSyncRef = useRef<(delay?: number, scheduleRun?: boolean) => void>(() => {});
const updateDiagnosticsRef = useRef<(patch: Partial<DiagnosticsState>) => void>(() => {});
const [diagnostics, setDiagnostics] = useState<DiagnosticsState>({
status: meshData ? 'initializing' : 'waiting for mesh',
webgl: 'pending',
@@ -239,6 +241,9 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
scheduleViewportSync(0, true);
}, [applyCameraState, scheduleViewportSync]);
scheduleViewportSyncRef.current = scheduleViewportSync;
updateDiagnosticsRef.current = updateDiagnostics;
// Initialize Three.js scene once
useEffect(() => {
const container = containerRef.current;
@@ -256,8 +261,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0x0f172a);
container.appendChild(renderer.domElement);
updateDiagnostics({
status: meshData ? 'renderer ready' : 'waiting for mesh',
updateDiagnosticsRef.current({
status: 'renderer ready',
webgl: `${renderer.capabilities.isWebGL2 ? 'webgl2' : 'webgl1'} / ${renderer.capabilities.precision}`,
canvas: `${renderer.domElement.width}x${renderer.domElement.height} px`,
render: `calls ${renderer.info.render.calls} tris ${renderer.info.render.triangles} geo ${renderer.info.memory.geometries}`,
@@ -266,13 +271,13 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
const handleContextLost = (event: Event) => {
event.preventDefault();
updateDiagnostics({
updateDiagnosticsRef.current({
status: 'webgl context lost',
error: 'WebGL context lost',
});
};
const handleContextRestored = () => {
updateDiagnostics({
updateDiagnosticsRef.current({
status: 'webgl context restored',
error: '',
});
@@ -303,7 +308,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
TWO: THREE.TOUCH.DOLLY_ROTATE,
};
renderer.domElement.style.touchAction = 'none';
const handleControlsEnd = () => scheduleViewportSync(120, true);
const handleControlsEnd = () => scheduleViewportSyncRef.current(120, true);
controls.addEventListener('end', handleControlsEnd);
// Lighting
@@ -341,7 +346,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
r.setSize(w, w);
c.aspect = 1;
c.updateProjectionMatrix();
updateDiagnostics({
updateDiagnosticsRef.current({
canvas: `${r.domElement.width}x${r.domElement.height} px`,
});
});
@@ -361,7 +366,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
}
threeRef.current = null;
};
}, [scheduleViewportSync, updateDiagnostics]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [applyCameraState]);
useEffect(() => {
if (meshData) {

46
frontend/src/nodeUsage.ts Normal file
View File

@@ -0,0 +1,46 @@
const STORAGE_KEY = 'tono_node_usage_counts';
let counts: Record<string, number> = loadFromStorage();
function loadFromStorage(): Record<string, number> {
if (typeof localStorage === 'undefined') return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return {};
return parsed;
} catch {
return {};
}
}
function persist(): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(counts));
} catch {
// ignore
}
}
export function recordUsage(className: string): void {
counts = { ...counts, [className]: (counts[className] || 0) + 1 };
persist();
}
export function getUsageCount(className: string): number {
return counts[className] || 0;
}
export function pickWeightedRandom(classNames: string[]): string | null {
if (classNames.length === 0) return null;
const weights = classNames.map((cn) => 1 / (1 + (counts[cn] || 0)));
const total = weights.reduce((a, b) => a + b, 0);
let r = Math.random() * total;
for (let i = 0; i < classNames.length; i++) {
r -= weights[i];
if (r <= 0) return classNames[i];
}
return classNames[classNames.length - 1];
}

View File

@@ -2515,6 +2515,11 @@ html, body, #root {
.ctx-cat-favorites .ctx-cat-label {
text-transform: none;
}
.ctx-random-node {
border-top: 1px solid var(--border-strong);
color: var(--text-secondary);
font-style: italic;
}
/* ── Submenu panel (separate fixed-position sibling) ── */
.ctx-submenu {