diff --git a/frontend/src/ContextMenu.tsx b/frontend/src/ContextMenu.tsx index 7b74ea5..fa4c50d 100644 --- a/frontend/src/ContextMenu.tsx +++ b/frontend/src/ContextMenu.tsx @@ -2,6 +2,9 @@ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { socketSpecAcceptsType } from './constants'; import { outputTypeCanConnectToTarget } from './connectionUtils'; import { compareMenuNodes, compareMenuCategories } from './canvasEvents'; +import { useFavorites } from './favorites'; + +const FAVORITES_CATEGORY = 'favorites'; export default function ContextMenu({ x, @@ -26,6 +29,7 @@ export default function ContextMenu({ selectedNodeCount?: number; onCreateGroup?: (() => void) | null; }) { + const favorites = useFavorites(); const [openCat, setOpenCat] = useState(null); const [search, setSearch] = useState(''); 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) => ({ ...category, items: [...category.items].sort(compareMenuNodes), })) .sort(compareMenuCategories); - }, [nodeDefs, filterDirection, filterSpec, filterType]); + + const favItems: any[] = []; + const seenFav = new Set(); + 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 const searchResults = useMemo(() => { @@ -262,10 +284,12 @@ export default function ContextMenu({
{ 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)} > - {cat} + + {cat === FAVORITES_CATEGORY ? '♥ favorites' : cat} +
))} diff --git a/frontend/src/CustomNode.tsx b/frontend/src/CustomNode.tsx index 0c8860f..0f53b1d 100644 --- a/frontend/src/CustomNode.tsx +++ b/frontend/src/CustomNode.tsx @@ -24,6 +24,7 @@ import { import { getGroupMinimumSize } from './groupSizing'; import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout'; 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'; @@ -1001,6 +1002,7 @@ function NodeTable({ rows }: { rows: Array> }) { function CustomNode({ id, data }: { id: string; data: NodeData }) { const ctx = useContext(NodeContext); const def = data.definition; + const favorited = useIsFavorite(data.className); const scalarDisplay = formatScalarDisplay(data.scalarValue); const processingTimeText = formatProcessingTime(data.processingTimeMs); const nodeWidth = useStore( @@ -1244,6 +1246,14 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
{data.label} +
{headerMeta && {headerMeta}} diff --git a/frontend/src/favorites.ts b/frontend/src/favorites.ts new file mode 100644 index 0000000..c83122f --- /dev/null +++ b/frontend/src/favorites.ts @@ -0,0 +1,68 @@ +import { useSyncExternalStore } from 'react'; + +const STORAGE_KEY = 'tono_favorite_nodes'; + +let favorites: Set = loadFromStorage(); +const listeners = new Set<() => void>(); + +function loadFromStorage(): Set { + 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 { + 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 { + return useSyncExternalStore(subscribe, getFavorites, getFavorites); +} + +export function useIsFavorite(className: string): boolean { + return useSyncExternalStore( + subscribe, + () => favorites.has(className), + () => favorites.has(className), + ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 438083f..cec2448 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -792,6 +792,34 @@ html, body, #root { 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-tabs { @@ -2484,6 +2512,9 @@ html, body, #root { .ctx-cat-active .ctx-cat-arrow { color: var(--text-primary); } +.ctx-cat-favorites .ctx-cat-label { + text-transform: none; +} /* ── Submenu panel (separate fixed-position sibling) ── */ .ctx-submenu {