add favorites

This commit is contained in:
2026-04-16 19:13:32 -07:00
parent ad48a40edc
commit 924b29757f
4 changed files with 137 additions and 4 deletions

View File

@@ -2,6 +2,9 @@ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { socketSpecAcceptsType } from './constants'; import { socketSpecAcceptsType } from './constants';
import { outputTypeCanConnectToTarget } from './connectionUtils'; import { outputTypeCanConnectToTarget } from './connectionUtils';
import { compareMenuNodes, compareMenuCategories } from './canvasEvents'; import { compareMenuNodes, compareMenuCategories } from './canvasEvents';
import { useFavorites } from './favorites';
const FAVORITES_CATEGORY = 'favorites';
export default function ContextMenu({ export default function ContextMenu({
x, x,
@@ -26,6 +29,7 @@ export default function ContextMenu({
selectedNodeCount?: number; selectedNodeCount?: number;
onCreateGroup?: (() => void) | null; onCreateGroup?: (() => void) | null;
}) { }) {
const favorites = useFavorites();
const [openCat, setOpenCat] = useState<string | null>(null); const [openCat, setOpenCat] = useState<string | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
@@ -88,13 +92,31 @@ export default function ContextMenu({
}); });
} }
} }
return Object.values(cats) const sorted = Object.values(cats)
.map((category: any) => ({ .map((category: any) => ({
...category, ...category,
items: [...category.items].sort(compareMenuNodes), items: [...category.items].sort(compareMenuNodes),
})) }))
.sort(compareMenuCategories); .sort(compareMenuCategories);
}, [nodeDefs, filterDirection, filterSpec, filterType]);
const favItems: any[] = [];
const seenFav = new Set<string>();
for (const category of sorted) {
for (const item of category.items) {
if (favorites.has(item.className) && !seenFav.has(item.className)) {
seenFav.add(item.className);
favItems.push(item);
}
}
}
if (favItems.length > 0) {
return [
{ name: FAVORITES_CATEGORY, order: -Infinity, items: favItems.sort(compareMenuNodes) },
...sorted,
];
}
return sorted;
}, [nodeDefs, filterDirection, filterSpec, filterType, favorites]);
// Flat filtered list for search // Flat filtered list for search
const searchResults = useMemo(() => { const searchResults = useMemo(() => {
@@ -262,10 +284,12 @@ export default function ContextMenu({
<div <div
key={cat} key={cat}
ref={(el) => { catRowRefs.current[cat] = el; }} ref={(el) => { catRowRefs.current[cat] = el; }}
className={`ctx-cat-item${openCat === cat ? ' ctx-cat-active' : ''}`} className={`ctx-cat-item${openCat === cat ? ' ctx-cat-active' : ''}${cat === FAVORITES_CATEGORY ? ' ctx-cat-favorites' : ''}`}
onMouseEnter={() => handleCatEnter(cat)} onMouseEnter={() => handleCatEnter(cat)}
> >
<span className="ctx-cat-label">{cat}</span> <span className="ctx-cat-label">
{cat === FAVORITES_CATEGORY ? '♥ favorites' : cat}
</span>
<span className="ctx-cat-arrow"></span> <span className="ctx-cat-arrow"></span>
</div> </div>
))} ))}

View File

@@ -24,6 +24,7 @@ import {
import { getGroupMinimumSize } from './groupSizing'; import { getGroupMinimumSize } from './groupSizing';
import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout'; import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout';
import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit, formatSI, parseSI } from './valueFormatting'; import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit, formatSI, parseSI } from './valueFormatting';
import { useIsFavorite, toggleFavorite } from './favorites';
import type { NodeData, NodeContextValue, InputSpec, InputOptions, WidgetDescriptor, PreviewPayload, OverlayData } from './types'; import type { NodeData, NodeContextValue, InputSpec, InputOptions, WidgetDescriptor, PreviewPayload, OverlayData } from './types';
@@ -1001,6 +1002,7 @@ function NodeTable({ rows }: { rows: Array<Record<string, unknown>> }) {
function CustomNode({ id, data }: { id: string; data: NodeData }) { function CustomNode({ id, data }: { id: string; data: NodeData }) {
const ctx = useContext(NodeContext); const ctx = useContext(NodeContext);
const def = data.definition; const def = data.definition;
const favorited = useIsFavorite(data.className);
const scalarDisplay = formatScalarDisplay(data.scalarValue); const scalarDisplay = formatScalarDisplay(data.scalarValue);
const processingTimeText = formatProcessingTime(data.processingTimeMs); const processingTimeText = formatProcessingTime(data.processingTimeMs);
const nodeWidth = useStore( const nodeWidth = useStore(
@@ -1244,6 +1246,14 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
<div className="node-title-left"> <div className="node-title-left">
<span className="node-title-main">{data.label}</span> <span className="node-title-main">{data.label}</span>
<button className="node-help-btn nodrag nopan" title="Documentation" onClick={(e) => { e.stopPropagation(); ctx?.openHelp(data.label); }}>?</button> <button className="node-help-btn nodrag nopan" title="Documentation" onClick={(e) => { e.stopPropagation(); ctx?.openHelp(data.label); }}>?</button>
<button
className={`node-fav-btn nodrag nopan${favorited ? ' is-favorited' : ''}`}
title={favorited ? 'Remove from favorites' : 'Add to favorites'}
aria-pressed={favorited}
onClick={(e) => { e.stopPropagation(); toggleFavorite(data.className); }}
>
{favorited ? '♥' : '♡'}
</button>
</div> </div>
{headerMeta && <span className="node-title-meta" title={headerMeta}>{headerMeta}</span>} {headerMeta && <span className="node-title-meta" title={headerMeta}>{headerMeta}</span>}
</div> </div>

68
frontend/src/favorites.ts Normal file
View File

@@ -0,0 +1,68 @@
import { useSyncExternalStore } from 'react';
const STORAGE_KEY = 'tono_favorite_nodes';
let favorites: Set<string> = loadFromStorage();
const listeners = new Set<() => void>();
function loadFromStorage(): Set<string> {
if (typeof localStorage === 'undefined') return new Set();
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((x): x is string => typeof x === 'string'));
} catch {
return new Set();
}
}
function persist(): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...favorites]));
} catch {
// Storage full or disabled — ignore.
}
}
function notify(): void {
for (const cb of listeners) cb();
}
export function getFavorites(): Set<string> {
return favorites;
}
export function isFavorite(className: string): boolean {
return favorites.has(className);
}
export function toggleFavorite(className: string): void {
const next = new Set(favorites);
if (next.has(className)) next.delete(className);
else next.add(className);
favorites = next;
persist();
notify();
}
function subscribe(cb: () => void): () => void {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
export function useFavorites(): Set<string> {
return useSyncExternalStore(subscribe, getFavorites, getFavorites);
}
export function useIsFavorite(className: string): boolean {
return useSyncExternalStore(
subscribe,
() => favorites.has(className),
() => favorites.has(className),
);
}

View File

@@ -792,6 +792,34 @@ html, body, #root {
border-color: var(--node-help-btn-border-hover); border-color: var(--node-help-btn-border-hover);
} }
.node-fav-btn {
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--node-help-btn-bg);
border: 1px solid var(--node-help-btn-border);
color: var(--node-help-btn-text);
font-size: 11px;
font-weight: 700;
line-height: 1;
padding: 0;
cursor: pointer;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.node-fav-btn:hover {
background: var(--node-help-btn-bg-hover);
border-color: var(--node-help-btn-border-hover);
}
.node-fav-btn.is-favorited {
color: #ffffff;
}
/* ── Node help panel ─────────────────────────────────────── */ /* ── Node help panel ─────────────────────────────────────── */
.node-help-tabs { .node-help-tabs {
@@ -2484,6 +2512,9 @@ html, body, #root {
.ctx-cat-active .ctx-cat-arrow { .ctx-cat-active .ctx-cat-arrow {
color: var(--text-primary); color: var(--text-primary);
} }
.ctx-cat-favorites .ctx-cat-label {
text-transform: none;
}
/* ── Submenu panel (separate fixed-position sibling) ── */ /* ── Submenu panel (separate fixed-position sibling) ── */
.ctx-submenu { .ctx-submenu {